From 198b7ce2d169cd01d5222c3ff90677a2942e943d Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Tue, 22 Sep 2026 12:37:52 -0500 Subject: [PATCH 01/14] Fix: Update copyright year. --- README.md | 2 +- docs/design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 047b884e..285595cf 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ Contributions to the code are welcome and encouraged! License ------- -Copyright (C) 2025 Bryan A. Jones. +Copyright (C) 2026 Bryan A. Jones. This file is part of the CodeChat Editor. diff --git a/docs/design.md b/docs/design.md index 9ff88e05..3997f3ba 100644 --- a/docs/design.md +++ b/docs/design.md @@ -274,7 +274,7 @@ added. License ------- -Copyright (C) 2025 Bryan A. Jones. +Copyright (C) 2026 Bryan A. Jones. This file is part of the CodeChat Editor. From b43e08d5a9d446bcb70833a6d7c7a7c8dc2552b6 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Wed, 23 Sep 2026 18:35:40 -0500 Subject: [PATCH 02/14] Revert "Add: use a instrumented loader for NAPI." This reverts commit 38b085c205ef05262ee9d2c90799b0e251d417ea. This added diagnostics to the NAPI startup, and was only use for pre-release testing. --- builder/src/main.rs | 17 +- extensions/VSCode/.gitignore | 6 +- extensions/VSCode/src/index.js | 1278 -------------------------------- 3 files changed, 4 insertions(+), 1297 deletions(-) delete mode 100644 extensions/VSCode/src/index.js diff --git a/builder/src/main.rs b/builder/src/main.rs index 263be2d0..6d45c646 100644 --- a/builder/src/main.rs +++ b/builder/src/main.rs @@ -724,21 +724,8 @@ fn run_extensions_build( let esbuild = PathBuf::from_slash("node_modules/.bin/esbuild"); let distflag = if dist { "--minify" } else { "--sourcemap" }; - // The NAPI build. `--js` aims the generated loader at - // `src/index-generated.js` instead of its default `src/index.js`, which - // is a tracked file carrying hand-written load tracing. The generated - // loader is therefore unused at run time; it's kept, and ignored by Git, - // as the reference to re-derive `src/index.js` from after a NAPI-RS - // upgrade changes what NAPI-RS emits. - let mut napi_args = vec![ - "napi", - "build", - "--platform", - "--output-dir", - "src", - "--js", - "index-generated.js", - ]; + // The NAPI build. + let mut napi_args = vec!["napi", "build", "--platform", "--output-dir", "src"]; if dist { napi_args.push("--release"); } diff --git a/extensions/VSCode/.gitignore b/extensions/VSCode/.gitignore index ce72840b..8dc0c274 100644 --- a/extensions/VSCode/.gitignore +++ b/extensions/VSCode/.gitignore @@ -29,11 +29,9 @@ log4rs.yml hashLocations.json static/ -# NAPI-RS. `src/index.js` is tracked rather than generated -- see the -# comment at the top of that file, and the `--js` flag in the builder's -# NAPI build. +# NAPI-RS src/index.d.ts -src/index-generated.js +src/index.js src/codechat-editor-client.win32-x64-msvc.node .windows/ *.log diff --git a/extensions/VSCode/src/index.js b/extensions/VSCode/src/index.js deleted file mode 100644 index dfc555c6..00000000 --- a/extensions/VSCode/src/index.js +++ /dev/null @@ -1,1278 +0,0 @@ -/* eslint-disable */ -// @ts-nocheck -/* originally generated by NAPI-RS, now tracked and edited by hand */ - -const { readFileSync, readdirSync } = require('fs') - -// Verbose load tracing -// -------------------- -// -// This section and the one which follows it are hand-written, as is the -// diagnosis they feed into the error thrown at the end of the file; the rest -// of this file is the loader NAPI-RS generates. The builder passes -// `--js index-generated.js` so that a NAPI build writes its loader beside -// this one rather than over it, leaving `index-generated.js` as the -// reference to re-derive this file from when a NAPI-RS upgrade changes -// what it emits. -// -// A binding which fails to load reaches the user as a single -// "Cannot find native binding" thrown during extension activation, naming -// neither the artifact which was tried nor the reason each attempt failed. -// Every decision and every `require` attempt below is therefore logged, so -// that an Extension Host log copied from the user's machine identifies the -// failing step. Set `CODECHAT_NAPI_DEBUG=0` to silence the output. -const __dbgEnabled = process.env.CODECHAT_NAPI_DEBUG !== '0' -const __dbg = (...args) => { - if (!__dbgEnabled) return - try { - console.log('[codechat-napi]', ...args) - } catch { - // A console which throws must not turn a working load into a failure. - } -} -// Render an error as a single line. What arrives here is whatever a failed -// `require` produced, which includes non-Errors thrown by an addon's own -// initialization code. -const __dbgErrText = (e) => { - try { - if (e && typeof e === 'object') { - return `${e.name || 'Error'}${e.code ? ' [' + e.code + ']' : ''}: ${e.message}` - } - return String(e) - } catch { - return '' - } -} -const __dbgStack = (e) => { - try { - return e && typeof e === 'object' && typeof e.stack === 'string' ? e.stack : '' - } catch { - return '' - } -} -// An array whose `push` announces what it collects. The loader funnels every -// failed attempt into one of two such arrays, so tracing `push` reports each -// failure without touching the `require` call sites -- which must keep their -// literal string arguments for esbuild's `.node` copy loader to find them. -const __dbgTracedArray = (label) => { - const array = [] - if (!__dbgEnabled) return array - const push = Array.prototype.push.bind(array) - array.push = (...items) => { - for (const item of items) { - __dbg(`${label} <- ${__dbgErrText(item)}`) - const stack = __dbgStack(item) - if (stack) __dbg(`${label} <- stack: ${stack}`) - } - return push(...items) - } - return array -} -const __dbgAttempt = (kind, specifier) => { - __dbg(`attempting ${kind}: ${specifier}`) -} -// Report a successful load, then hand the binding back, so that this wraps an -// existing expression instead of restructuring the block it sits in. -const __dbgOk = (specifier, binding) => { - let keys - try { - keys = binding ? Object.keys(binding).join(', ') : String(binding) - } catch (e) { - keys = `` - } - __dbg(`LOADED ${specifier} -- exports: ${keys}`) - return binding -} -const LF = String.fromCharCode(10) -const CR = String.fromCharCode(13) -const SPACE = '' -const NL_INDENT = LF + ' ' -const __dbgChain = (label, errors) => { - __dbg(`${label}: ${errors.length} error(s) recorded`) - errors.forEach((e, i) => { - __dbg(` [${i}] ${__dbgErrText(e)}`) - const stack = __dbgStack(e) - // Indent a multi-line stack so that it reads as one entry in the log. - if (stack) __dbg(NL_INDENT.slice(1) + stack.split(CR).join(SPACE).split(LF).join(NL_INDENT)) - }) -} - -// Diagnosing a Windows load failure -// --------------------------------- -// -// `LoadLibraryExW` reports a dependency it cannot find as -// `ERROR_MOD_NOT_FOUND` against the path it was asked to load, not against the -// dependency. An artifact which the directory listing logged below shows -// sitting in place is therefore named as the file which could not be found, -// and Windows offers no way to ask which dependency was actually absent. So -// read the artifact's own import table and look for each name it holds along -// the directories the loader searches: `uv_dlopen` passes -// `LOAD_WITH_ALTERED_SEARCH_PATH`, which searches the artifact's directory -// first, then the system directory and `PATH`. -const { closeSync, existsSync, openSync, readSync } = require('fs') -const { basename, dirname, join } = require('path') - -// A name beginning with `api-ms-` or `ext-ms-` is an API set rather than a -// library: the loader resolves it through a schema held in the kernel, and no -// file of that name exists on disk, so searching directories can never find -// one. -const __napiIsApiSet = (dllName) => /^(api|ext)-ms-/i.test(dllName) - -// The libraries which arrive with the Visual C++ redistributable rather than -// with Windows. A build which links the C runtime dynamically imports -// `VCRUNTIME140.dll` from this set, and a machine which has never installed -// the redistributable does not have it -- the failure which -// `.cargo/config.toml` now heads off by linking that runtime statically. -const __napiIsRedistributableDll = (dllName) => - /^(vcruntime|msvcp|concrt|vcomp|vccorlib)\d+(_\w+)?\.dll$/i.test(dllName) - -// Microsoft's permalink to the current redistributable, which covers every -// release from 2015 on. It is the architecture of *this process* which -// decides the download: an x64 VSCode emulated on an ARM64 machine loads an -// x64 artifact, which the ARM64 redistributable cannot satisfy. -const __napiRedistributableUrl = () => - `https://aka.ms/vs/17/release/vc_redist.${process.arch === 'arm64' ? 'arm64' : process.arch === 'ia32' ? 'x86' : 'x64'}.exe` - -// The `Machine` field of the COFF header, in the terms `process.arch` uses. -// An artifact built for the wrong architecture fails to load exactly as one -// whose dependencies are missing does, so the field is read to tell the two -// apart. -const __napiMachineArch = { - 0x014c: 'ia32', - 0x01c4: 'arm', - 0x8664: 'x64', - 0xaa64: 'arm64', -} - -// Read a run of bytes from an open file. The artifact runs to tens of -// megabytes, so follow the offsets its headers hold rather than reading the -// whole of it. -const __napiReadAt = ( - // The open file to read from. - fd, - // The offset to read at. - position, - // How many bytes to read. - length, - // The bytes read, which fall short of `length` only at end of file. -) => { - const buffer = Buffer.alloc(length) - const read = readSync(fd, buffer, 0, length, position) - return buffer.subarray(0, read) -} - -// List the libraries named in a Windows executable's import table. -const __napiPeImports = ( - // The path of the artifact to read. - artifactPath, - // The COFF `Machine` field and the imported names, or `null` for a file - // which does not parse as a Windows executable -- itself a finding, since a - // truncated or overwritten artifact fails to load in the same way as one - // whose dependencies are missing. -) => { - const fd = openSync(artifactPath, 'r') - try { - // An `MZ` signature, then the offset of the PE header at the end of the - // DOS header which follows it. - const dosHeader = __napiReadAt(fd, 0, 0x40) - if (dosHeader.length < 0x40 || dosHeader.readUInt16LE(0) !== 0x5a4d) { - return null - } - const peOffset = dosHeader.readUInt32LE(0x3c) - // A 4-byte `PE\0\0` signature, then the 20-byte COFF header, which counts - // the sections and sizes the optional header following it. - const coffHeader = __napiReadAt(fd, peOffset, 24) - if (coffHeader.length < 24 || coffHeader.readUInt32LE(0) !== 0x00004550) { - return null - } - const machine = coffHeader.readUInt16LE(4) - const sectionCount = coffHeader.readUInt16LE(6) - const optionalHeaderSize = coffHeader.readUInt16LE(20) - const optionalHeader = __napiReadAt(fd, peOffset + 24, optionalHeaderSize) - if (optionalHeader.length < optionalHeaderSize) { - return null - } - // A 64-bit image -- `PE32+`, magic `0x20b` -- widens five fields of the - // optional header to 64 bits -- `ImageBase` and the four stack and heap - // sizes, gaining 20 bytes -- and drops a sixth, `BaseOfData`, losing 4, - // so the data directories sit 16 bytes further along than in a 32-bit - // image. Each directory is an address followed by a size, and the second - // of them is the import table. - const directories = optionalHeader.readUInt16LE(0) === 0x20b ? 112 : 96 - const importRva = optionalHeader.readUInt32LE(directories + 8) - if (importRva === 0) { - return { machine, names: [] } - } - const sectionTable = __napiReadAt( - fd, - peOffset + 24 + optionalHeaderSize, - 40 * sectionCount, - ) - if (sectionTable.length < 40 * sectionCount) { - return null - } - const sections = [] - for (let index = 0; index < sectionCount; index++) { - const entry = sectionTable.subarray(40 * index, 40 * (index + 1)) - sections.push({ - virtualAddress: entry.readUInt32LE(12), - // A section's size on disk and its size once mapped differ; the - // larger of the two bounds it. - size: Math.max(entry.readUInt32LE(8), entry.readUInt32LE(16)), - rawOffset: entry.readUInt32LE(20), - }) - } - // Addresses in the import table are relative to the image as the loader - // maps it, which is not how it sits in the file: each section lands at - // its own offset, so only an address falling inside a section can be - // read at all. - const fileOffset = (rva) => { - const section = sections.find( - (candidate) => - rva >= candidate.virtualAddress && - rva < candidate.virtualAddress + candidate.size, - ) - return section === undefined - ? null - : section.rawOffset + (rva - section.virtualAddress) - } - let descriptor = fileOffset(importRva) - if (descriptor === null) { - return null - } - const names = [] - // The table holds one 20-byte descriptor per library and ends with a - // descriptor of all zeros. Only the name at offset 12 matters here; the - // rest addresses the individual functions imported. - while (true) { - const entry = __napiReadAt(fd, descriptor, 20) - if (entry.length < 20 || entry.every((byte) => byte === 0)) { - break - } - const nameOffset = fileOffset(entry.readUInt32LE(12)) - if (nameOffset === null) { - return null - } - // `MAX_PATH` bounds the name, which ends at its first NUL. - const name = __napiReadAt(fd, nameOffset, 260) - const end = name.indexOf(0) - names.push(name.toString('latin1', 0, end === -1 ? name.length : end)) - descriptor += 20 - } - return { machine, names } - } finally { - closeSync(fd) - } -} - -// Report which of the libraries an artifact imports cannot be found along the -// path the loader searches. -const __napiMissingImports = ( - // The path of the artifact to examine. - artifactPath, - // The architecture the artifact was built for -- `undefined` for a machine - // type this does not name -- together with the names which no searched - // directory holds, or `null` where the import table could not be read. -) => { - const parsed = __napiPeImports(artifactPath) - if (parsed === null) { - return null - } - // Windows permits a `PATH` entry containing spaces to be quoted and strips - // the quotes itself before searching, so an entry left as written never - // matches a directory, and every library living there is reported missing. - const searchPath = [ - dirname(artifactPath), - join(process.env.SystemRoot || 'C:\\Windows', 'System32'), - ...(process.env.PATH || '').split(';'), - ].map((directory) => directory.replace(/"/g, '').trim()) - const missing = [] - const seen = new Set() - for (const name of parsed.names) { - // One library draws several descriptors when the objects importing from - // it spell its name differently -- `kernel32.dll` and `KERNEL32.dll` both - // appear in these artifacts. - const key = name.toLowerCase() - if (seen.has(key) || __napiIsApiSet(name)) { - continue - } - seen.add(key) - if ( - !searchPath.some( - (directory) => directory !== '' && existsSync(join(directory, name)), - ) - ) { - missing.push(name) - } - } - return { arch: __napiMachineArch[parsed.machine], missing } -} - -// Explain a failure of the native chain on Windows. Without this, such a -// failure reaches the user as the generic message thrown below, which sends -// them looking for a file that is present and blames npm, which has no part -// in installing an extension from the Marketplace. -const __napiDiagnoseWindowsLoadFailure = ( - // Every error the native chain recorded. - errors, - // A message naming the cause, or `null` where nothing beyond the recorded - // errors can be said. -) => { - if (process.platform !== 'win32') { - return null - } - try { - // `require` reports an artifact which Windows refused to load as - // `ERR_DLOPEN_FAILED` and one which is absent as `MODULE_NOT_FOUND`, so - // this distinguishes a load which failed from an artifact which was - // never installed. - const dlopenError = errors.find( - (e) => e && typeof e === 'object' && e.code === 'ERR_DLOPEN_FAILED', - ) - const directory = typeof __dirname === 'string' ? __dirname : '.' - const artifacts = readdirSync(directory).filter((entry) => - entry.endsWith('.node'), - ) - if (dlopenError === undefined) { - return artifacts.length === 0 - ? `No native module for ${process.platform}-${process.arch} was installed with this extension: ${directory} holds none. Uninstall the CodeChat Editor extension, then install it again.` - : null - } - // A localized Windows writes this message in its own language, so take - // from it only the path, which is predictable, and fall back to the - // directory's sole artifact where even that cannot be read. - const message = - typeof dlopenError.message === 'string' ? dlopenError.message : '' - const messagePath = /[A-Za-z]:\\[^\r\n]*?\.node/.exec(message) - let artifactPath = messagePath === null ? null : messagePath[0] - if (artifactPath === null || !existsSync(artifactPath)) { - artifactPath = - artifacts.length === 1 ? join(directory, artifacts[0]) : null - } - if (artifactPath === null) { - return null - } - const parsed = __napiMissingImports(artifactPath) - if (parsed === null) { - return `${artifactPath} is installed, but does not parse as a Windows library, so Windows cannot load it. The file is most likely truncated or damaged: uninstall the CodeChat Editor extension, then install it again.` - } - const { arch, missing } = parsed - // An artifact built for another architecture is refused with the same - // `ERROR_MOD_NOT_FOUND` as one whose dependencies are absent, and its - // imports all resolve, so without this check it is blamed on security - // software below. - if (arch !== undefined && arch !== process.arch) { - return `${artifactPath} was built for ${arch}, but this VSCode runs as ${process.arch}, so Windows cannot load it. The wrong artifact was installed for this machine: uninstall the CodeChat Editor extension, then install it again.` - } - if (missing.length === 0) { - // Nothing here identifies the cause, so pass the Windows text on rather - // than replacing it. An addon built against another Node ABI is the one - // remaining cause this can name. - return `Windows refused to load ${artifactPath}, although the file is present, built for ${process.arch}, and every library it names was found. Windows reported: ${message.replace(/\s+/g, ' ').replace(/[\s.]+$/, '')}. Check whether security software has blocked or altered ${basename(artifactPath)}, and whether this addon was built for the Node ABI this VSCode uses (NODE_MODULE_VERSION ${process.versions.modules}).` - } - return ( - `Windows cannot load ${artifactPath}: it needs ${missing.join(', ')}, which ${missing.length === 1 ? 'is' : 'are'} not installed on this machine. ` + - (missing.some(__napiIsRedistributableDll) - ? `Install the Microsoft Visual C++ Redistributable from ${__napiRedistributableUrl()}, then restart VSCode.` - : `Install the software providing ${missing.length === 1 ? 'it' : 'them'}, then restart VSCode.`) - ) - } catch (e) { - // The diagnosis runs only once the load has already failed. A fault in it - // must not replace the failure it was meant to explain. - __dbg(`diagnosis failed: ${__dbgErrText(e)}`) - return null - } -} - -__dbg('==== binding load starting ====') -__dbg(`platform=${process.platform} arch=${process.arch} node=${process.version} modules=${process.versions.modules} electron=${process.versions.electron || 'none'}`) -__dbg(`execPath=${process.execPath}`) -__dbg(`cwd=${process.cwd()}`) -__dbg(`__dirname=${typeof __dirname === 'string' ? __dirname : ''}`) -__dbg(`config.variables: shlib_suffix=${process.config && process.config.variables ? process.config.variables.shlib_suffix : ''} node_target_type=${process.config && process.config.variables ? process.config.variables.node_target_type : ''}`) -for (const __dbgEnvName of [ - 'NAPI_RS_NATIVE_LIBRARY_PATH', - 'NAPI_RS_FORCE_WASI', - 'NAPI_RS_WASI_FLAVOR', - 'NAPI_RS_ENFORCE_VERSION_CHECK', -]) { - __dbg(`env ${__dbgEnvName}=${process.env[__dbgEnvName] === undefined ? '' : JSON.stringify(process.env[__dbgEnvName])}`) -} -// The artifact is looked for beside this file, so a listing of that directory -// shows directly whether the expected `.node` was packaged and installed. -try { - const __dbgDir = typeof __dirname === 'string' ? __dirname : '.' - const __dbgEntries = readdirSync(__dbgDir) - __dbg(`contents of ${__dbgDir} (${__dbgEntries.length} entries): ${__dbgEntries.join(', ')}`) -} catch (e) { - __dbg(`directory listing failed: ${__dbgErrText(e)}`) -} -let nativeBinding = null -// Which artifact actually loaded. The WASI fallback chain overwrites it with -// the flavor it resolved; the late native retry below leaves it alone because -// it only runs while no WASI candidate has been loaded. -let __napiLoadedBindingTarget = 'native' -const loadErrors = __dbgTracedArray('loadErrors') - -const isMusl = () => { - let musl = false - if (process.platform === 'linux') { - musl = isMuslFromFilesystem() - if (musl === null) { - musl = isMuslFromReport() - } - if (musl === null) { - musl = isMuslFromChildProcess() - } - } - __dbg(`isMusl() = ${musl}`) - return musl -} - -const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') - -const isMuslFromFilesystem = () => { - try { - return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') - } catch { - return null - } -} - -const isMuslFromReport = () => { - let report = null - if (process.report && typeof process.report.getReport === 'function') { - process.report.excludeNetwork = true - report = process.report.getReport() - } - if (!report) { - return null - } - if (report.header && report.header.glibcVersionRuntime) { - return false - } - if (Array.isArray(report.sharedObjects)) { - if (report.sharedObjects.some(isFileMusl)) { - return true - } - } - return false -} - -const isMuslFromChildProcess = () => { - try { - return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') - } catch (e) { - // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false - return false - } -} - -function requireNative() { - __dbg('requireNative(): walking the platform/arch chain') - if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { - try { - __dbgAttempt('NAPI_RS_NATIVE_LIBRARY_PATH override', process.env.NAPI_RS_NATIVE_LIBRARY_PATH) - const overrideBinding = __dbgOk(process.env.NAPI_RS_NATIVE_LIBRARY_PATH, require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)) - // The override may be a generated WASI loader, which already reports its - // own flavor. Adopt it: `module.exports` aliases this object, so claiming - // 'native' would both misreport the artifact and overwrite the loader's - // marker through the alias. - __napiLoadedBindingTarget = - overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string' - ? overrideBinding.__napiBindingTarget - : 'native' - return overrideBinding - } catch (err) { - loadErrors.push(err) - } - } else if (process.platform === 'android') { - if (process.arch === 'arm64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.android-arm64.node') - return __dbgOk('./codechat-editor-client.android-arm64.node', require('./codechat-editor-client.android-arm64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-android-arm64') - const binding = require('codechat-editor-client-android-arm64') - const bindingPackageVersion = require('codechat-editor-client-android-arm64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-android-arm64', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.android-arm-eabi.node') - return __dbgOk('./codechat-editor-client.android-arm-eabi.node', require('./codechat-editor-client.android-arm-eabi.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-android-arm-eabi') - const binding = require('codechat-editor-client-android-arm-eabi') - const bindingPackageVersion = require('codechat-editor-client-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-android-arm-eabi', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) - } - } else if (process.platform === 'win32') { - if (process.arch === 'x64') { - if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.win32-x64-gnu.node') - return __dbgOk('./codechat-editor-client.win32-x64-gnu.node', require('./codechat-editor-client.win32-x64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-win32-x64-gnu') - const binding = require('codechat-editor-client-win32-x64-gnu') - const bindingPackageVersion = require('codechat-editor-client-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-win32-x64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.win32-x64-msvc.node') - return __dbgOk('./codechat-editor-client.win32-x64-msvc.node', require('./codechat-editor-client.win32-x64-msvc.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-win32-x64-msvc') - const binding = require('codechat-editor-client-win32-x64-msvc') - const bindingPackageVersion = require('codechat-editor-client-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-win32-x64-msvc', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'ia32') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.win32-ia32-msvc.node') - return __dbgOk('./codechat-editor-client.win32-ia32-msvc.node', require('./codechat-editor-client.win32-ia32-msvc.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-win32-ia32-msvc') - const binding = require('codechat-editor-client-win32-ia32-msvc') - const bindingPackageVersion = require('codechat-editor-client-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-win32-ia32-msvc', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.win32-arm64-msvc.node') - return __dbgOk('./codechat-editor-client.win32-arm64-msvc.node', require('./codechat-editor-client.win32-arm64-msvc.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-win32-arm64-msvc') - const binding = require('codechat-editor-client-win32-arm64-msvc') - const bindingPackageVersion = require('codechat-editor-client-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-win32-arm64-msvc', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) - } - } else if (process.platform === 'darwin') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.darwin-universal.node') - return __dbgOk('./codechat-editor-client.darwin-universal.node', require('./codechat-editor-client.darwin-universal.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-darwin-universal') - const binding = require('codechat-editor-client-darwin-universal') - const bindingPackageVersion = require('codechat-editor-client-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-darwin-universal', binding) - } catch (e) { - loadErrors.push(e) - } - if (process.arch === 'x64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.darwin-x64.node') - return __dbgOk('./codechat-editor-client.darwin-x64.node', require('./codechat-editor-client.darwin-x64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-darwin-x64') - const binding = require('codechat-editor-client-darwin-x64') - const bindingPackageVersion = require('codechat-editor-client-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-darwin-x64', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.darwin-arm64.node') - return __dbgOk('./codechat-editor-client.darwin-arm64.node', require('./codechat-editor-client.darwin-arm64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-darwin-arm64') - const binding = require('codechat-editor-client-darwin-arm64') - const bindingPackageVersion = require('codechat-editor-client-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-darwin-arm64', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) - } - } else if (process.platform === 'freebsd') { - if (process.arch === 'x64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.freebsd-x64.node') - return __dbgOk('./codechat-editor-client.freebsd-x64.node', require('./codechat-editor-client.freebsd-x64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-freebsd-x64') - const binding = require('codechat-editor-client-freebsd-x64') - const bindingPackageVersion = require('codechat-editor-client-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-freebsd-x64', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.freebsd-arm64.node') - return __dbgOk('./codechat-editor-client.freebsd-arm64.node', require('./codechat-editor-client.freebsd-arm64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-freebsd-arm64') - const binding = require('codechat-editor-client-freebsd-arm64') - const bindingPackageVersion = require('codechat-editor-client-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-freebsd-arm64', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) - } - } else if (process.platform === 'linux') { - if (process.arch === 'x64') { - if (isMusl()) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-x64-musl.node') - return __dbgOk('./codechat-editor-client.linux-x64-musl.node', require('./codechat-editor-client.linux-x64-musl.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-x64-musl') - const binding = require('codechat-editor-client-linux-x64-musl') - const bindingPackageVersion = require('codechat-editor-client-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-x64-musl', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-x64-gnu.node') - return __dbgOk('./codechat-editor-client.linux-x64-gnu.node', require('./codechat-editor-client.linux-x64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-x64-gnu') - const binding = require('codechat-editor-client-linux-x64-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-x64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'arm64') { - if (isMusl()) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-arm64-musl.node') - return __dbgOk('./codechat-editor-client.linux-arm64-musl.node', require('./codechat-editor-client.linux-arm64-musl.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-arm64-musl') - const binding = require('codechat-editor-client-linux-arm64-musl') - const bindingPackageVersion = require('codechat-editor-client-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-arm64-musl', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-arm64-gnu.node') - return __dbgOk('./codechat-editor-client.linux-arm64-gnu.node', require('./codechat-editor-client.linux-arm64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-arm64-gnu') - const binding = require('codechat-editor-client-linux-arm64-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-arm64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'arm') { - if (isMusl()) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-arm-musleabihf.node') - return __dbgOk('./codechat-editor-client.linux-arm-musleabihf.node', require('./codechat-editor-client.linux-arm-musleabihf.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-arm-musleabihf') - const binding = require('codechat-editor-client-linux-arm-musleabihf') - const bindingPackageVersion = require('codechat-editor-client-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-arm-musleabihf', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-arm-gnueabihf.node') - return __dbgOk('./codechat-editor-client.linux-arm-gnueabihf.node', require('./codechat-editor-client.linux-arm-gnueabihf.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-arm-gnueabihf') - const binding = require('codechat-editor-client-linux-arm-gnueabihf') - const bindingPackageVersion = require('codechat-editor-client-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-arm-gnueabihf', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'loong64') { - if (isMusl()) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-loong64-musl.node') - return __dbgOk('./codechat-editor-client.linux-loong64-musl.node', require('./codechat-editor-client.linux-loong64-musl.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-loong64-musl') - const binding = require('codechat-editor-client-linux-loong64-musl') - const bindingPackageVersion = require('codechat-editor-client-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-loong64-musl', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-loong64-gnu.node') - return __dbgOk('./codechat-editor-client.linux-loong64-gnu.node', require('./codechat-editor-client.linux-loong64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-loong64-gnu') - const binding = require('codechat-editor-client-linux-loong64-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-loong64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'riscv64') { - if (isMusl()) { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-riscv64-musl.node') - return __dbgOk('./codechat-editor-client.linux-riscv64-musl.node', require('./codechat-editor-client.linux-riscv64-musl.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-riscv64-musl') - const binding = require('codechat-editor-client-linux-riscv64-musl') - const bindingPackageVersion = require('codechat-editor-client-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-riscv64-musl', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-riscv64-gnu.node') - return __dbgOk('./codechat-editor-client.linux-riscv64-gnu.node', require('./codechat-editor-client.linux-riscv64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-riscv64-gnu') - const binding = require('codechat-editor-client-linux-riscv64-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-riscv64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } - } else if (process.arch === 'ppc64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-ppc64-gnu.node') - return __dbgOk('./codechat-editor-client.linux-ppc64-gnu.node', require('./codechat-editor-client.linux-ppc64-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-ppc64-gnu') - const binding = require('codechat-editor-client-linux-ppc64-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-ppc64-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 's390x') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.linux-s390x-gnu.node') - return __dbgOk('./codechat-editor-client.linux-s390x-gnu.node', require('./codechat-editor-client.linux-s390x-gnu.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-linux-s390x-gnu') - const binding = require('codechat-editor-client-linux-s390x-gnu') - const bindingPackageVersion = require('codechat-editor-client-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-linux-s390x-gnu', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) - } - } else if (process.platform === 'openharmony') { - if (process.arch === 'arm64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.openharmony-arm64.node') - return __dbgOk('./codechat-editor-client.openharmony-arm64.node', require('./codechat-editor-client.openharmony-arm64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-openharmony-arm64') - const binding = require('codechat-editor-client-openharmony-arm64') - const bindingPackageVersion = require('codechat-editor-client-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-openharmony-arm64', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'x64') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.openharmony-x64.node') - return __dbgOk('./codechat-editor-client.openharmony-x64.node', require('./codechat-editor-client.openharmony-x64.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-openharmony-x64') - const binding = require('codechat-editor-client-openharmony-x64') - const bindingPackageVersion = require('codechat-editor-client-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-openharmony-x64', binding) - } catch (e) { - loadErrors.push(e) - } - } else if (process.arch === 'arm') { - try { - __dbgAttempt('local artifact', './codechat-editor-client.openharmony-arm.node') - return __dbgOk('./codechat-editor-client.openharmony-arm.node', require('./codechat-editor-client.openharmony-arm.node')) - } catch (e) { - loadErrors.push(e) - } - try { - __dbgAttempt('optional dependency package', 'codechat-editor-client-openharmony-arm') - const binding = require('codechat-editor-client-openharmony-arm') - const bindingPackageVersion = require('codechat-editor-client-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.2.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - return __dbgOk('codechat-editor-client-openharmony-arm', binding) - } catch (e) { - loadErrors.push(e) - } - } else { - loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) - } - } else { - loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) - } -} - -function createLoadErrorChain(errors) { - return errors.reduce((previous, current) => { - let message - try { - message = - current && typeof current.message === 'string' - ? current.message - : String(current) - } catch { - message = 'Unknown error' - } - const error = new Error(message) - error.cause = previous - return error - }, null) -} - -// NAPI_RS_FORCE_WASI is a tri-state flag: -// unset / any other value → native binding preferred, WASI is only a fallback -// 'true' → prefer WASI, but retain native as a lazy fallback -// 'error' → require WASI without initializing a native fallback -// Treating any non-empty string as truthy (the historical behavior) meant -// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered -// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. -// -// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict -// WASI loading. It never crosses into another flavor or falls back to native. -const __napiWasiFlavors = ['wasm32-wasi'] -const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR -const __napiWasiFlavorRequested = - typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 -if ( - __napiWasiFlavorRequested && - __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 -) { - throw new Error( - 'Unsupported WASI flavor "' + - __napiWasiFlavor + - '". Available flavors: ' + - __napiWasiFlavors.join(', '), - ) -} -const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' -const forceWasi = - process.env.NAPI_RS_FORCE_WASI === 'true' || - forceWasiError || - __napiWasiFlavorRequested - -__dbg( - `WASI selection: forceWasi=${forceWasi} forceWasiError=${forceWasiError} flavorRequested=${__napiWasiFlavorRequested} flavor=${__napiWasiFlavor || ''}`, -) -if (!forceWasi) { - nativeBinding = requireNative() - __dbg(`requireNative() returned ${nativeBinding ? 'a binding' : 'nothing'}`) -} else { - __dbg('skipping the native chain: WASI was forced') -} - -if (!nativeBinding || forceWasi) { - __dbg('entering the WASI fallback chain') - let wasiBinding = null - let wasiBindingLoaded = false - const wasiBindingErrors = __dbgTracedArray('wasiBindingErrors') - const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { - try { - require.resolve(specifier) - } catch (resolveError) { - if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { - throw resolveError - } - if (isPackage) { - try { - require.resolve(specifier + '/package.json') - } catch (packageError) { - if (packageError && packageError.code === 'MODULE_NOT_FOUND') { - return resolveError - } - // An exports restriction proves the package exists even when its - // package.json is not public. Preserve the root resolution failure. - throw resolveError - } - // The package exists but its main/export target is broken. - throw resolveError - } - return resolveError - } - if (localArtifacts) { - let artifactError = null - for (let i = 0; i < localArtifacts.length; i++) { - try { - require.resolve(localArtifacts[i]) - return null - } catch (resolveError) { - if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { - throw resolveError - } - artifactError = resolveError - } - } - return artifactError - } - return null - } - if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { - let candidateError = null - let candidateFailed = false - try { - candidateError = __napiWasiResolveCandidate('./codechat-editor-client.wasi.cjs', false, ['./codechat-editor-client.wasm32-wasi.debug.wasm', './codechat-editor-client.wasm32-wasi.wasm']) - candidateFailed = candidateError !== null - if (!candidateFailed) { - __dbgAttempt('WASI local artifact', './codechat-editor-client.wasi.cjs') - wasiBinding = __dbgOk( - './codechat-editor-client.wasi.cjs', - require('./codechat-editor-client.wasi.cjs'), - ) - nativeBinding = wasiBinding - __napiLoadedBindingTarget = 'wasm32-wasi' - wasiBindingLoaded = true - } - } catch (err) { - candidateError = err - candidateFailed = true - } - if (candidateFailed) { - wasiBindingErrors.push(candidateError) - loadErrors.push(candidateError) - } - } - if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { - let candidateError = null - let candidateFailed = false - try { - candidateError = __napiWasiResolveCandidate('codechat-editor-client-wasm32-wasi', true, undefined) - candidateFailed = candidateError !== null - if (!candidateFailed) { - if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - const bindingPackageVersion = require('codechat-editor-client-wasm32-wasi/package.json').version - if (bindingPackageVersion !== '0.2.5') { - throw new Error(`WASI binding package version mismatch, expected 0.2.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) - } - } - __dbgAttempt('WASI package', 'codechat-editor-client-wasm32-wasi') - wasiBinding = __dbgOk( - 'codechat-editor-client-wasm32-wasi', - require('codechat-editor-client-wasm32-wasi'), - ) - nativeBinding = wasiBinding - __napiLoadedBindingTarget = 'wasm32-wasi' - wasiBindingLoaded = true - } - } catch (err) { - candidateError = err - candidateFailed = true - } - if (candidateFailed) { - wasiBindingErrors.push(candidateError) - loadErrors.push(candidateError) - } - } - if ( - !wasiBindingLoaded && - forceWasi && - !forceWasiError && - !__napiWasiFlavorRequested - ) { - __dbg('WASI was preferred but not found; retrying the native chain') - nativeBinding = requireNative() - } - if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { - const error = new Error( - __napiWasiFlavorRequested - ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' - : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', - ) - __dbgChain('WASI load failed', wasiBindingErrors) - __dbg(`THROWING: ${error.message}`) - error.cause = createLoadErrorChain(wasiBindingErrors) - throw error - } -} - -__dbg( - `binding resolution finished: ${nativeBinding ? 'SUCCESS' : 'FAILURE'}, target=${__napiLoadedBindingTarget}`, -) -if (!nativeBinding) { - if (loadErrors.length > 0) { - // Lead with a diagnosis of the Windows failure where one can be reached: - // it names the cause, while the message below names only the symptom. - const windowsDiagnosis = __napiDiagnoseWindowsLoadFailure(loadErrors) - if (windowsDiagnosis !== null) { - __dbg(`diagnosis: ${windowsDiagnosis}`) - } - const error = new Error( - windowsDiagnosis === null - ? `Cannot find native binding. ` + - `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + - 'Please try `npm i` again after removing both package-lock.json and node_modules directory.' - : windowsDiagnosis, - ) - // assign instead of the `new Error(message, { cause })` options form, - // which Node < 16.9 silently ignores - __dbgChain('every load attempt failed', loadErrors) - __dbg(`THROWING: ${error.message}`) - error.cause = createLoadErrorChain(loadErrors) - throw error - } - __dbg('THROWING: Failed to load native binding, with no attempt recorded') - throw new Error(`Failed to load native binding`) -} - -function __napiStampBindingTarget(exportsObject, target) { - if ( - Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget') - ) { - if (exportsObject.__napiBindingTarget === target) { - // Already ours: the root entry aliases the object it loaded, so a WASI - // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that - // is a generated loader — arrives already stamped with this same value. - return target - } - const error = new Error( - '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', - ) - error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' - throw error - } - if (!Object.isExtensible(exportsObject)) { - // A `#[napi(module_exports)]` hook may seal or freeze this object - // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, - // never a reason to fail an otherwise successful load, so the stamp is - // skipped. What a consumer still sees then follows the entry point: the - // browser and deferred loaders declare `__napiBindingTarget` at module - // level and go on reporting it, while the CommonJS entries hand back this - // very object as `module.exports`, so there the value is absent. - return target - } - try { - // [[Define]], not [[Set]]: an ordinary assignment walks the prototype - // chain, so an inherited accessor could swallow the value or throw and - // fail an otherwise successful load. The descriptor is what a successful - // assignment would have produced. - Object.defineProperty(exportsObject, '__napiBindingTarget', { - configurable: true, - enumerable: true, - value: target, - writable: true, - }) - } catch { - // Same rule as the non-extensible skip above: reporting the artifact is - // metadata, never a reason to fail an otherwise successful load. An exotic - // object (a Proxy whose defineProperty trap refuses) is skipped, not - // thrown over. - } - // The CommonJS loaders assign this return value so `cjs-module-lexer` — and - // therefore Node's CJS -> ESM named export detection — can see - // `__napiBindingTarget` statically. - return target -} -// Stamp before the alias, not after. The guard only reads `nativeBinding` -// (`hasOwnProperty` plus a comparison), which is safe against any addon -// accessor; an assignment is not, because a `#[napi(module_exports)]` hook can -// expose a getter reporting this very value and a setter that throws. So the -// assignment lands on the loader's own `module.exports`, still the original -// object here, and the alias below replaces it. -// -// The assignment is what keeps the marker a statically visible CommonJS export: -// `cjs-module-lexer` is Node's CJS -> ESM named export detection, it cannot see -// a bare call, and the later `module.exports = nativeBinding` does not undo the -// detection. The assignment itself always succeeds — its target is this -// loader's own, still extensible `module.exports` — and the alias below then -// discards the value it wrote. What a consumer reads is whatever the guard put -// on `nativeBinding`, so on a frozen binding, where the guard skips, the -// linked import resolves to `undefined`. -module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget) -module.exports = nativeBinding -module.exports.CodeChatEditorServer = nativeBinding.CodeChatEditorServer -module.exports.initServer = nativeBinding.initServer - -__dbg( - `exports check: CodeChatEditorServer=${typeof nativeBinding.CodeChatEditorServer} initServer=${typeof nativeBinding.initServer}`, -) -__dbg('==== binding load finished ====') From c70f2529cc614a0b9db9ea2c610325386a241839 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 1 Oct 2026 21:23:07 -0500 Subject: [PATCH 03/14] wip: cache --- extensions/standalone/src/filewatcher.rs | 2 +- server/Cargo.toml | 3 + server/src/processing.rs | 12 +- server/src/processing/cache-spec.md | 164 +++++++++++--- server/src/processing/cache.rs | 204 +++++++++++++++++- server/src/webserver.rs | 9 +- .../tests/test_walk_project/.hidden/c.md | 1 + .../cache/tests/test_walk_project/.ignore | 1 + .../cache/tests/test_walk_project/a.md | 1 + .../cache/tests/test_walk_project/ignored.md | 1 + .../cache/tests/test_walk_project/sub/b.py | 1 + .../tests/test_walk_project/sub/ignored.md | 1 + 12 files changed, 353 insertions(+), 47 deletions(-) create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.hidden/c.md create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.ignore create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/a.md create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/ignored.md create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/b.py create mode 100644 server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/ignored.md diff --git a/extensions/standalone/src/filewatcher.rs b/extensions/standalone/src/filewatcher.rs index 82cca999..36b2aed4 100644 --- a/extensions/standalone/src/filewatcher.rs +++ b/extensions/standalone/src/filewatcher.rs @@ -447,7 +447,7 @@ fn processing_task( let (watcher_tx, mut watcher_rx) = mpsc::channel(10); // Watch this file. Use the debouncer, to avoid multiple notifications // for the same file. This approach returns a result of either a working - // debouncer or any errors that occurred. The debouncer's scope needs + // debouncer or any errors that occurred. The debouncer's scope needs to // live as long as this connection does; dropping it early means losing // file change notifications. let Ok(mut debounced_watcher) = new_debouncer( diff --git a/server/Cargo.toml b/server/Cargo.toml index 4e3f2513..92ec62ec 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -57,16 +57,19 @@ htmd = { git = "https://github.com/bjones1/htmd.git", branch = "dom-interface", # This must match the version of `markup5ever_rcdom`. html5ever = "0.39" htmlize = "1.0.6" +ignore = "0.4" imara-diff = { version = "0.2", features = [] } indoc = "2.0.5" log.workspace = true log4rs = "1.3" +lru = "0.18.5" markup5ever_rcdom = "0.39" mime = "0.3.17" mime_guess = "2.0.5" minify-html = { git = "https://github.com/bjones1/minify-html.git", branch = "dev", version = "0.18.1" } minreq = { version = "3", features = ["https-rustls-probe", "proxy"] } normalize-line-endings = "0.3.0" +notify-debouncer-full = "0.7.0" path-slash = "0.2.1" pest = "2.7.14" pest_derive = "2.7.14" diff --git a/server/src/processing.rs b/server/src/processing.rs index be899c5e..eab97ef0 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -914,13 +914,8 @@ pub fn source_to_codechat_for_web( project_cache } else { // A non-project file uses a throwaway cache whose "project" is the - // file's directory: only references within this file resolve. - Arc::new(Mutex::new(Cache::new( - file_path - .parent() - .unwrap_or_else(|| Path::new("")) - .to_path_buf(), - ))) + // file itself: only references within this file resolve. + Arc::new(Mutex::new(Cache::new(file_path.to_path_buf()))) }; let code_doc_block_arr; let codechat_for_web = CodeChatForWeb { @@ -1285,8 +1280,7 @@ pub fn source_to_codechat_for_web_string( cache .lock() .unwrap() - .entry(root.clone()) - .or_insert_with(|| Arc::new(Mutex::new(Cache::new(root.clone())))) + .get_or_insert_ref(&root, || Arc::new(Mutex::new(Cache::new(root.clone())))) .clone() }); diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 0906ea59..6028505e 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -17,7 +17,7 @@ do not. The cache reflects data read directly from disk/IDE; content edited in the Client does not update the cache until it's written to disk/IDE, at which point -the cache much re-process this unknown file. Since the Client is designed around +the cache must re-process this unknown file. Since the Client is designed around an autosave principle which updates disk/IDE regularly, there's little gap between the two. @@ -110,9 +110,9 @@ Details: attribute on an element without an `id` produces an error message which requests the missing `id`. * All ids must be valid - [CSS identifiers](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/es/ident) + [CSS identifiers](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident) per - [MDN recommendations](https://developer.mozilla.org/en-US/docs/Web/HTML/rence/Global_attributes/id). + [MDN recommendations](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/id). Invalid ids produce error messages in the hydrated tag content. Example hydration of the gather tag `

Bazzy @@ -141,8 +141,8 @@ gather elements; search does not include `Fragment` contents. ### Auto-assignment of ids If `id="*"` on either a fragment, target, or gather element, the cache replaces -this with an random autogenerated `id` placed in the resulting HTML contents, -but this new `id` is not yet recorded in the cache. The file is then marked as +this with a random autogenerated `id` placed in the resulting HTML contents, but +this new `id` is not yet recorded in the cache. The file is then marked as `Unknown` when it is saved; when re-read, this `id` is then incorporated into the cache. This helps avoid cases where the cache and file contents become unsynchronized: if the `id` is placed in the cache before the write and the @@ -150,7 +150,7 @@ write fails, or if the file is never written (it was being scanned, but not actively edited, so the file contents wasn't written back). The autogenerated id must be a valid -[CSS identifier](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/es/ident). +[CSS identifier](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident). Autogenerated ids must be checked to make sure they don't collide with an existing id. @@ -162,7 +162,9 @@ all consistency comes from that one lock, so no per-item locking (and therefore no lock ordering) is needed. Items refer to each other by key -- files by path, targets and fragments by id -- rather than by `Arc`/`Weak` pointers. This keeps the structure acyclic, `Send`, and (in the future) serializable, and avoids -garbage-collecting stale weak references. +garbage-collecting stale weak references. Note that the walker, watcher, and +consumer task cache members aren't serializable, but can be re-created after +deserialization. Cached state always converges by design. Each layer depends only on lower-numbered layers: @@ -265,21 +267,133 @@ HTML stored as `Target` inner HTML or in `Fragment` contents requires cleaning: * Inner HTML must allow only the [permitted content for an `` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#technical_summary). -Watcher and walker ------------------- - -Need a watcher/walker (WW). Each time a project is found, the code must ask for -the cache for this project path. The WW maintains a connection id -> project -path mapping. If the current request doesn't change the mapping, simply return -the existing cache. If the mapping changes, then update the WW thingy. The map -must also be updated when a connection is closed (to remove an existing -mapping). - -The WW thingy is another map from project path -> (cache, vec of project paths -that are contained with this path, including the same path \[when multiple -connections edit the same project\]). Operations: - -* Insert: to insert a new project path, walk all existing top-level project - paths. If the new project path is contained within any of these, add it to the - appropriate list. Otherwise, add a new entry. -* Delete: find the path my looking fir +Cache, watcher, and walker +-------------------------- + +The `AppState` structure keeps an LRU map of project path -> cache (note that +the cache includes a watcher and walker). Adding/removing map entries resizes +the LRU size to (connections \* `LRU_ENTRIES_PER_CONNECTION`) + 1. This approach +seeks to balance cache memory consumption with the time required to load the +cache for an uncached project. + +The cache fully supports multiple connections to the same project. It does not +walk symlinks, since these could lead to two cache entries for the same file. +There's no explicit support for nested projects. While it's possible for one +project to be nested inside another, this design does not support this case. + +The cache initially registers a watcher with the provided project path and a +consumer task which receives results from the walker, along with a message +channel connecting them. + +### Watcher + +The watcher thread monitors the root directory of the project and all children +for changes using the +[notify-debouncer-full](https://docs.rs/notify-debouncer-full/latest/notify_debouncer_full/) +crate. It first registers the watcher, to ensure that any changes during the +walk are still reported. Even if the watcher fails, it also starts a filesystem +walker task rooted at the project path, initially sending it the root directory +to walk and providing it the consumer channel and a second walker channel. It +sends file names of files that were created, modified, or deleted and the path +of created or deleted directories to the walker encoded as `WatcherPathState`s +via the walker channel; renames are treated as a delete of the `from` path +followed by a create of the `to` path. If the watcher reports lost events +(`EventKind::Other` with `Flag::Rescan`), the watcher requests a re-walk of the +entire project. A closed channel signals the watcher to exit. If the watcher +can't be started, the user is informed as a warning; any failures to watch files +produce logger warnings, but are not sent to the user. Note that this approach +means extraneous watcher events from ignored directories, such as `.git`, and +additional use of OS resources such as inotify watches. + +### Walker + +The walker is based on the [ignore](https://docs.rs/ignore/latest/ignore/) +crate, allowing users to exclude files from the project. It is implemented as a +thread to support blocking I/O. After the initial traverse of the project, it +keeps an +[IncrementalIgnore](https://docs.rs/ignore/latest/ignore/struct.IncrementalIgnore.html) +for checking files in the `WatcherPathState`s received from the watcher. + +* If the path is a deleted file or directory and is not ignored by the walker, + it's sent directly to the consumer task. TODO: can the walker produce a valid + ignore decision on a deleted file/directory? Otherwise: +* If the path refers to any of the walker's ignore files (per the + [ignore docs](https://docs.rs/ignore/latest/ignore/struct.WalkBuilder.html), + this is `.gitignore`, `.git/info/exclude`, and `.ignore`), the path containing this file is re-walked. For simplicity, this occurs even outside a Git repository. TODO: also have the watcher monitor global ignore + files. +* If the path is a file and is not ignored by the walker, it's sent to the + consumer task. +* If the path is a directory, it's walked; any files found are sent to the + consumer task. + +Before performing a walk, the walker sends a `WalkStarted` to the consumer task; +when completed, a `WalkFinished`. + +After processing a path, the walker then loops to wait for the next path. The +task ends when either channel is closed (indicating the cache was dropped). It +ignores any errors produced during the walk, assuming that files it can't walk +aren't usable by the IDE. + +### Consumer + +A consumer task reads paths from the walker channel. It holds a weak reference +to the cache, and exits when this channel is closed. It is implemented as an +async task to integrate with the processing pipeline while distributing the CPU +time required to translate files. + +* If the file exists, is translatable, and differs from its cached metadata, it + translates the file to the CodeChatForWeb format in order to update the cache + for that file. If the `SaveOnScan` option is enabled, it then translates the + file back to source code and writes the result if there are any changes, + update the file's metadata in the cache after the write. This costs additional + CPU time and produces watcher events and may rewrite files open in the IDE, + motivates the choice of a user-selected option. + + If processing a file marks any connection's current file or its TOC as + outdated, the connection(s) are sent a message to retranslate the file/IDE + contents; otherwise, the file is added to the list of files to process. It + does not process the current file for any of its connections; these are + handled by each connection. + +* If the path doesn't exist, search the cache for it; if found, remove it. + Otherwise, assume the path was a directory. Search the cache for all files in + this directory, removing them. While storing files in hierarchical directories + would make directory operations faster, this is premature optimization. This + design choice is rejected before profiling the result. + +* If this is a `WalkStarted`, put all files in the cache which this directory + contains in a set. Remove files produced by the walk from this set. After + `WalkFinished`, remove any files remaining in the set from the cache, since + these no longer exist. + +The cache doesn't track changes to images or multimedia files referenced by a +source file, requiring the user to manually refresh the page when these files +change. + +### Shutdown + +Overall shutdown sequence: + +1. The LRU map drops a `Cache` and the upgraded Weak reference by the consumer + task is dropped. +2. The `Cache` drops the `_watcher` and the consumer task. +3. When dropped, the `_watcher` closes its channel to the walker and exits. +4. The walker responds to the closed watcher channel by closing its channel to + the consumer and exits. +5. The consumer task exits when either the walker channel closes or the weak + pointer fails to upgrade. + +TODO: + +* How to make each connection's current file visible to the cache? +* How does the consumer task tell the Client to update the current file? +* Does the consumer need to be async? Or can it be a plain thread? +* Need a new message saying reload the TOC. + +


+ +


+ +


+ +


diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index d2847df2..fce5e9ea 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -1,8 +1,3 @@ -// TODO: Remove these once outdated-file scheduling is implemented. It will use -// `root`, plus the hooks a file watcher or project scan needs -- `remove_file` -// and `mark_unknown` -- which nothing calls yet. -#![allow(unused_variables)] -#![allow(unused)] // Copyright (C) 2026 Bryan A. Jones. // // This file is part of the CodeChat Editor. The CodeChat Editor is free @@ -32,10 +27,24 @@ use std::{ mem, path::{Path, PathBuf}, sync::{Arc, Mutex}, + thread, + time::Duration, }; // ### Third-party +use ignore::WalkBuilder; +use log::error; +use lru::LruCache; +use notify_debouncer_full::{ + DebounceEventResult, Debouncer, RecommendedCache, new_debouncer, + notify::{ + self, + EventKind::{Create, Modify, Remove}, + RecommendedWatcher, RecursiveMode, + }, +}; use rand::random; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; // ### Local // @@ -47,7 +56,9 @@ use rand::random; /// directory containing its `toc.md`). The outer mutex guards the addition of /// newly-discovered projects; each inner mutex guards a single project's cache, /// so that work on one project doesn't block work on another. -pub type CacheMap = Arc>>>>; +pub type CacheMap = Arc>>>>; + +pub const LRU_ENTRIES_PER_CONNECTION: usize = 10; /// This defines the cache used to store all targets in a project. pub struct Cache { @@ -57,10 +68,18 @@ pub struct Cache { /// Provide rapid access to a `Target`/gather element or `Fragment` by its /// id. pub(super) ids: HashMap, - /// All files for which `FileEntry::status == FileStatus::Unknown`. + /// All files for which `FileEntry::status != FileStatus::UpToDate`. pub(super) pending_files: Vec, - /// The root directory of this project. + /// The root directory of this project, or a specific file if this is a + /// single-file "project". pub(super) root: PathBuf, + /// Files found by the walker thread (see `walk_project`) or the watcher + /// (see `watch_project`). Dropping the cache drops this receiver, which + /// closes the channel and so stops both. + found_files: UnboundedReceiver, + /// This project's watcher, or `None` for a single-file "project" or if the + /// watcher couldn't start. Dropping it stops the watch. + _watcher: Option>, // TODO: search engine data storage. Search fields: target ID, contents, // file name. Perhaps [Tantivy](https://docs.rs/tantivy/latest/tantivy/)? } @@ -267,20 +286,65 @@ pub(super) struct GatherBacklink<'a> { pub(super) target: &'a Target, } +/// States of paths discovered by the watcher. +pub(super) enum WatcherPathState { + CreatedOrModified(PathBuf), + Deleted(PathBuf), +} + +/// Messages sent by the walker to the consumer task. +pub(super) enum ConsumerPathState { + WatcherPathState(WatcherPathState), + WalkStarted(PathBuf), + WalkFinished +} + // Code // ---- impl Cache { + /// Create an empty cache, then start a watcher and a walker thread which + /// report the project's files; `queue_found_files` collects what they find. #[must_use] pub fn new( // The root directory of this project, as a canonicalized absolute path; - // all cached files must live within it. + // all cached files must live within it. For a single-file "project", + // this is that file. root: PathBuf, ) -> Self { + let (found_tx, found_rx) = mpsc::unbounded_channel(); + // Start watching before walking, so that a file created during the + // walk is reported by one or the other. A watcher failure isn't fatal: + // the cache still works, but won't learn of files which change after + // the walk. On Linux, this happens once the per-user inotify limits are + // reached, since each watcher uses its own inotify instance. + let _watcher = if root.is_dir() { + let watcher = watch_project(&root, found_tx.clone()) + .inspect_err(|err| error!("Unable to watch {}: {err}", root.display())) + .ok(); + // Start the walker next. + let walk_root = root.clone(); + thread::spawn(move || walk_project(&walk_root, &found_tx)); + watcher + } else { + None + }; Cache { files: HashMap::new(), ids: HashMap::new(), pending_files: vec![], root, + found_files: found_rx, + _watcher, + } + } + + /// Mark every file the walker has found so far, which the cache doesn't + /// already know of, as `Unknown`, queueing it for processing. + pub(super) fn queue_found_files(&mut self) { + while let Ok(path) = self.found_files.try_recv() { + if !self.files.contains_key(&path) { + self.mark_unknown(&path); + } } } @@ -842,6 +906,88 @@ fn gathered_ids<'a>( .flat_map(|target| target.gather_ids.iter()) } +/// Watch `root` recursively, sending the path of every file created, modified, +/// or removed within it to the cache. This runs on the debouncer's thread. +fn watch_project( + // The project's root directory. + root: &Path, + // Where to send each file reported; the cache owns the receiving end. + found: UnboundedSender, + // The running watcher; dropping it stops the watch. +) -> notify::Result> { + // The watcher only needs to learn of files added to or removed from the + // project. notify 8 has no `Config::with_event_kinds`, so the event handler + // must discard events which don't match this mask itself. + // + // TODO: pass this mask to `Config::with_event_kinds` once + // notify-debouncer-full depends on notify 9. + let mut debouncer = new_debouncer( + Duration::from_secs(2), + None, + move |result: DebounceEventResult| match result { + Ok(debounced_event_vec) => { + for debounced_event in debounced_event_vec { + match debounced_event.event.kind { + Create(_) | Modify(_) | Remove(_) => { + for path in debounced_event.event.paths { + if let Err(err) = found.send(path) { + // The only possible source of [error](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html#errors) per the docs is a closed channel. This occurs when the cache is dropped, meaning the watcher should stop. + return; + } + } + } + _ => (), + } + } + } + // TODO: should we sent errors to the cache for better UI? Or ignore them, given they're files we couldn't watch, and therefore couldn't access anyway? + Err(err_vec) => { + for err in err_vec { + error!("Error: {err}"); + } + } + }, + )?; + debouncer.watch(root, RecursiveMode::Recursive)?; + Ok(debouncer) +} + +/// Send every file under `path` -- or `path` itself, if it's a file -- to the +/// cache, stopping early once the cache is dropped. This runs on its own +/// thread, spawned by `Cache::new`. +/// +/// The walk applies the [ignore](https://docs.rs/ignore) crate's default +/// filters, which are ripgrep's: it skips hidden files and directories (such as +/// `.git`), anything excluded by an `.ignore` file, and -- only inside a git +/// repository -- anything excluded by `.gitignore` or git's other exclude +/// rules. +fn walk_project( + // The file or directory to walk. + path: &Path, + // Where to send each file found; the cache owns the receiving end. + found: &UnboundedSender, +) { + // The walk reports an error (an unreadable directory, say) as an entry of + // its own, then continues; skip these. + for entry in WalkBuilder::new(path).build().flatten() { + // Check before every entry, not just when sending a file, so a walk + // through a large tree containing few files still stops promptly. + if found.is_closed() { + return; + } + // Symlinks aren't followed, so a symlink loop can't make this walk + // forever. + if entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + && found.send(entry.into_path()).is_err() + { + // Just as the watcher, a send failure means the cache is dropped, so exit. + return; + } + } +} + impl Default for Cache { fn default() -> Self { Cache::new(PathBuf::new()) @@ -857,7 +1003,12 @@ mod tests { path::{Path, PathBuf}, }; - use super::{Cache, FileFacts, FileStatus, FragmentFact, IdResolution, IdState, TargetFact}; + use test_utils::prep_test_dir; + use tokio::sync::mpsc; + + use super::{ + Cache, FileFacts, FileStatus, FragmentFact, IdResolution, IdState, TargetFact, walk_project, + }; // ### Test helpers // @@ -1449,4 +1600,37 @@ mod tests { let outdated = cache.update_fragment_content(&a, "frag", "content".to_string()); assert!(outdated.is_empty()); } + + // ### Walker tests + // + // Verify that the walker finds every file in a nested tree except those + // which are hidden or excluded by an `.ignore` file, and that a single-file + // root yields just that file. + #[test] + fn test_walk_project() { + let (temp_dir, test_dir) = prep_test_dir!(); + let a = test_dir.join("a.md"); + let b = test_dir.join("sub").join("b.py"); + // Confirm that copying the fixture preserved the hidden files, so their + // absence from the walk below is the walker's doing. + assert!(test_dir.join(".ignore").is_file()); + assert!(test_dir.join(".hidden").join("c.md").is_file()); + + // Walk synchronously, returning the sorted list of files found. + let walk = |path: &Path| { + let (found_tx, mut found_rx) = mpsc::unbounded_channel(); + walk_project(path, &found_tx); + let mut found = vec![]; + while let Ok(path) = found_rx.try_recv() { + found.push(path); + } + found.sort(); + found + }; + assert_eq!(walk(&test_dir), vec![a.clone(), b]); + assert_eq!(walk(&a), vec![a]); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } } diff --git a/server/src/webserver.rs b/server/src/webserver.rs index 459fb96f..f2d3d0eb 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -24,6 +24,7 @@ pub mod tests; // ------- // // ### Standard library +use core::num::NonZeroUsize; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -63,6 +64,7 @@ use log4rs::{ self, config::{Deserializers, load_config_file}, }; +use lru::LruCache; use mime::Mime; use mime_guess; use path_slash::PathBufExt; @@ -86,7 +88,8 @@ use url::Url; // ### Local //use crate::capture::EventCapture; use crate::processing::{ - CodeChatForWeb, SourceToCodeChatForWebError, TranslationResultsString, cache::CacheMap, + CodeChatForWeb, SourceToCodeChatForWebError, TranslationResultsString, + cache::{CacheMap, LRU_ENTRIES_PER_CONNECTION}, find_path_to_toc, source_to_codechat_for_web_string, }; @@ -1688,7 +1691,9 @@ fn make_app_data_with_capture_spool( connection_id: Mutex::new(HashSet::new()), credentials, capture, - cache: Arc::new(Mutex::new(HashMap::new())), + cache: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(LRU_ENTRIES_PER_CONNECTION).unwrap(), + ))), }) } diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.hidden/c.md b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.hidden/c.md new file mode 100644 index 00000000..bdc5323f --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.hidden/c.md @@ -0,0 +1 @@ +# In a hidden directory; the walker skips this file. diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.ignore b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.ignore new file mode 100644 index 00000000..e2be7aa9 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/.ignore @@ -0,0 +1 @@ +ignored.md diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/a.md b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/a.md new file mode 100644 index 00000000..33c942e3 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/a.md @@ -0,0 +1 @@ +# Found by the walker. diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/ignored.md b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/ignored.md new file mode 100644 index 00000000..372d43dc --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/ignored.md @@ -0,0 +1 @@ +# Excluded by `.ignore`; the walker skips this file. diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/b.py b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/b.py new file mode 100644 index 00000000..33c942e3 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/b.py @@ -0,0 +1 @@ +# Found by the walker. diff --git a/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/ignored.md b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/ignored.md new file mode 100644 index 00000000..9ad4908b --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/processing/cache/tests/test_walk_project/sub/ignored.md @@ -0,0 +1 @@ +# Excluded by `.ignore` in the parent directory; the walker skips this file. From f0ea84679e398916a509f67d843774ef4054eb0c Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 1 Oct 2026 21:25:30 -0500 Subject: [PATCH 04/14] wip: - cache knows current_file for all connections. - cache can send re-translations of the current file to the Client. --- server/src/ide/vscode/tests.rs | 217 +++++++++++- server/src/processing.rs | 51 +-- server/src/processing/cache-spec.md | 63 ++-- server/src/processing/cache.rs | 168 +++++++++- server/src/translation.rs | 310 ++++++++++++------ .../tests/test_vscode_ide_websocket10/test.py | 1 + .../tests/test_vscode_ide_websocket10/toc.md | 1 + 7 files changed, 664 insertions(+), 147 deletions(-) create mode 100644 server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/test.py create mode 100644 server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md diff --git a/server/src/ide/vscode/tests.rs b/server/src/ide/vscode/tests.rs index f0553cfd..5ad93a2f 100644 --- a/server/src/ide/vscode/tests.rs +++ b/server/src/ide/vscode/tests.rs @@ -59,7 +59,7 @@ use crate::{ use crate::{ processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, - CodeMirrorDocBlockTransaction, SourceFileMetadata, StringDiff, + CodeMirrorDocBlockTransaction, CodeMirrorDocBlockUpdate, SourceFileMetadata, StringDiff, }, webserver::{ResultOkTypes, UpdateMessageContents, drop_leading_slash}, }; @@ -1442,6 +1442,221 @@ async fn test_vscode_ide_websocket9() { temp_dir.close().unwrap(); } +/// Verify that the Server re-translates the current file when its project's +/// cache reports it's outdated: the current file cross-references an id which +/// only the TOC defines, so loading the TOC resolves the cross-reference. +#[actix_web::test] +async fn test_vscode_ide_websocket10() { + let connection_id = "test-connection-id10"; + let (temp_dir, test_dir, mut ws_ide, mut ws_client) = prep_test!(connection_id).await; + open_client(&mut ws_ide).await; + + // Load the current file, following the protocol in + // `test_vscode_ide_websocket4`. + let file_path_temp = fs::canonicalize(test_dir.join("test.py")).unwrap(); + let file_path = simplified(&file_path_temp); + let file_path_str = file_path.to_str().unwrap().to_string(); + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile( + format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}", + file_path.to_slash().unwrap() + ), + None, + ), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_ide).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile(file_path_str.clone(), Some(true)) + } + ); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) + } + ); + + // Fetch the current file, which is open in the IDE. (Only when the IDE + // supplies the current file's contents does the Server diff against them.) + let test_dir_thread = test_dir.clone(); + let join_handle = thread::spawn(move || { + assert_eq!( + minreq::get(format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/test.py", + test_dir_thread.to_slash().unwrap(), + )) + .send() + .unwrap() + .status_code, + 200 + ); + }); + let em = read_message(&mut ws_ide).await; + cast!(em.message, EditorMessageContents::LoadFile, a, b); + assert_eq!(em.id, INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(Some(( + fs::read_to_string(&file_path_temp).unwrap(), + 1.0, + ))))), + }, + ) + .await; + join_handle.join().unwrap(); + + // The cross-reference can't be resolved yet, since the TOC hasn't been + // translated. + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: file_path_str.clone(), + cursor_position: None, + scroll_position: None, + is_re_translation: false, + contents: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "python".to_string(), + }, + source: CodeMirrorDiffable::Plain(CodeMirror { + doc: "\n".to_string(), + doc_blocks: vec![CodeMirrorDocBlock { + from: 0, + to: 1, + indent: String::new(), + delimiter: "#".to_string(), + contents: r#"

See id "foo" not found."#.to_string(), + }], + }), + version: 1.0, + }), + }), + } + ); + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_ide).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + } + ); + + // Fetch the TOC, which defines the id the current file cross-references. + let test_dir_thread = test_dir.clone(); + let join_handle = thread::spawn(move || { + assert_eq!( + minreq::get(format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/toc.md?mode=toc", + test_dir_thread.to_slash().unwrap() + )) + .send() + .unwrap() + .status_code, + 200 + ); + }); + let em = read_message(&mut ws_ide).await; + cast!(em.message, EditorMessageContents::LoadFile, a, b); + assert_eq!(em.id, INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), + }, + ) + .await; + join_handle.join().unwrap(); + + // Translating the TOC outdates the current file, which the Server + // re-translates, sending the Client the resolved cross-reference. The + // source is unchanged, so the version is too. + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: file_path_str.clone(), + cursor_position: None, + scroll_position: None, + is_re_translation: true, + contents: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "python".to_string(), + }, + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: vec![], + doc_blocks: vec![CodeMirrorDocBlockTransaction::Update( + CodeMirrorDocBlockUpdate { + from: 0, + from_new: None, + to: None, + indent: None, + delimiter: None, + contents: vec![StringDiff { + from: 0, + to: Some(97), + insert: "

See Foo.".to_string(), + }], + } + )], + version: 1.0, + }), + version: 1.0, + }), + }), + } + ); + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_ide).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + } + ); + + check_logger_errors(0); + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); +} + #[test] fn test_find_eoltypes() { assert_eq!( diff --git a/server/src/processing.rs b/server/src/processing.rs index eab97ef0..28361df2 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -415,6 +415,35 @@ pub fn find_path_to_toc(file_path: &Path) -> Option { } } +/// Return the cache of the project containing a file, creating it if this +/// project has none. +pub fn project_cache( + // The map of project caches. + cache_map: &CacheMap, + // A file in the project. + file_path: &Path, + // The path to the project's TOC, relative to the directory containing + // `file_path`, as returned by `find_path_to_toc`. + path_to_toc: &Path, +) -> Arc> { + // The project root is the directory containing `toc.md`. Canonicalize it + // so that every file in a project keys the same cache, and different + // projects key different caches. + let toc_path = file_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path_to_toc); + let root = toc_path + .parent() + .map_or_else(PathBuf::new, Path::to_path_buf); + let root = canonicalize(&root).unwrap_or(root); + cache_map + .lock() + .unwrap() + .get_or_insert_ref(&root, || Arc::new(Mutex::new(Cache::new(root.clone())))) + .clone() +} + #[derive(Debug, thiserror::Error)] pub enum CodechatForWebToSourceError { #[error("invalid lexer {0}")] @@ -1264,25 +1293,9 @@ pub fn source_to_codechat_for_web_string( // file by searching the current directory, then all its parents, for a file // named `toc.md`. let path_to_toc = find_path_to_toc(file_path); - let cache: Option>> = path_to_toc.as_ref().map(|path_to_toc| { - // The project root is the directory containing `toc.md`. `path_to_toc` - // is relative to the file's directory; canonicalize the combination so - // that every file in a project keys the same cache, and different - // projects key different caches. - let toc_path = file_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(path_to_toc); - let root = toc_path - .parent() - .map_or_else(PathBuf::new, Path::to_path_buf); - let root = canonicalize(&root).unwrap_or(root); - cache - .lock() - .unwrap() - .get_or_insert_ref(&root, || Arc::new(Mutex::new(Cache::new(root.clone())))) - .clone() - }); + let cache = path_to_toc + .as_ref() + .map(|path_to_toc| project_cache(cache, file_path, path_to_toc)); Ok(( match source_to_codechat_for_web(file_contents, file_path, version, is_toc, cache) { diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 6028505e..758b282e 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -315,12 +315,12 @@ keeps an for checking files in the `WatcherPathState`s received from the watcher. * If the path is a deleted file or directory and is not ignored by the walker, - it's sent directly to the consumer task. TODO: can the walker produce a valid - ignore decision on a deleted file/directory? Otherwise: + it's sent directly to the consumer task. * If the path refers to any of the walker's ignore files (per the [ignore docs](https://docs.rs/ignore/latest/ignore/struct.WalkBuilder.html), - this is `.gitignore`, `.git/info/exclude`, and `.ignore`), the path containing this file is re-walked. For simplicity, this occurs even outside a Git repository. TODO: also have the watcher monitor global ignore - files. + this is `.gitignore`, `.git/info/exclude`, and `.ignore`), the path containing + this file is re-walked. For simplicity, this occurs even outside a Git + repository. TODO: also have the watcher monitor global ignore files. * If the path is a file and is not ignored by the walker, it's sent to the consumer task. * If the path is a directory, it's walked; any files found are sent to the @@ -337,29 +337,30 @@ aren't usable by the IDE. ### Consumer A consumer task reads paths from the walker channel. It holds a weak reference -to the cache, and exits when this channel is closed. It is implemented as an -async task to integrate with the processing pipeline while distributing the CPU -time required to translate files. +to the cache, and exits when this channel is closed or the weak reference can't +be upgraded. It is implemented as an async task to integrate with the processing +pipeline while distributing the CPU time required to translate files. * If the file exists, is translatable, and differs from its cached metadata, it translates the file to the CodeChatForWeb format in order to update the cache for that file. If the `SaveOnScan` option is enabled, it then translates the file back to source code and writes the result if there are any changes, - update the file's metadata in the cache after the write. This costs additional - CPU time and produces watcher events and may rewrite files open in the IDE, - motivates the choice of a user-selected option. + updating the file's metadata in the cache after the write. This costs + additional CPU time and produces watcher events and may rewrite files open in + the IDE, motivating the choice of a user-selected option. If processing a file marks any connection's current file or its TOC as outdated, the connection(s) are sent a message to retranslate the file/IDE - contents; otherwise, the file is added to the list of files to process. It - does not process the current file for any of its connections; these are - handled by each connection. + contents (see `Connections`); otherwise, the file is added to the list of + files to process. It does not process the current file for any of its + connections; these are handled by each connection. * If the path doesn't exist, search the cache for it; if found, remove it. Otherwise, assume the path was a directory. Search the cache for all files in - this directory, removing them. While storing files in hierarchical directories - would make directory operations faster, this is premature optimization. This - design choice is rejected before profiling the result. + this directory, removing them.
Problem: if a path doesn't exist, perhaps + it's not translatable. This would mean a lot of expensive directory searches + for regular files. Consider some sort of hierarchical filesystem mirror for + this case. * If this is a `WalkStarted`, put all files in the cache which this directory contains in a set. Remove files produced by the walk from this set. After @@ -370,6 +371,31 @@ The cache doesn't track changes to images or multimedia files referenced by a source file, requiring the user to manually refresh the page when these files change. +### Connections + +Each connection's translation task owns its current file. The cache keeps a +copy for each connection whose current file lies in its project, keyed by +connection id, along with a channel for notifying that connection. A +`ConnectionRegistration` owns each entry: when the current file changes, the +translation task drops its registration, removing the entry from the old +project's cache, then registers with the new file's project; when the connection +closes, dropping the registration removes the entry. Keeping this registry +inside the cache means that checking whether a file is some connection's current +file, and committing that file, happen under the same lock. + +Whenever a commit marks files outdated, the cache sends `CurrentFileOutdated` to +each connection whose current file is among them. The connection re-translates +its current file from the IDE's contents, which may be newer than the file on +disk, then sends any change to the Client as a re-translation. Since the source +is unchanged, the re-translation keeps the file's version, and the IDE isn't +updated. The Client ignores a re-translation while its own edits are unsent or +in flight; those edits are re-translated when they reach the Server. + +A registration holds a strong reference to its cache, so the cache outlives its +LRU map entry while any connection views a file in its project. TODO: keep the +LRU map from evicting a cache with registered connections; until then, the next +translation in an evicted project creates a second cache for it. + ### Shutdown Overall shutdown sequence: @@ -377,7 +403,8 @@ Overall shutdown sequence: 1. The LRU map drops a `Cache` and the upgraded Weak reference by the consumer task is dropped. 2. The `Cache` drops the `_watcher` and the consumer task. -3. When dropped, the `_watcher` closes its channel to the walker and exits. +3. When dropped, the `_watcher` closes its channel to the walker, stops the + watcher, and exits. 4. The walker responds to the closed watcher channel by closing its channel to the consumer and exits. 5. The consumer task exits when either the walker channel closes or the weak @@ -385,8 +412,6 @@ Overall shutdown sequence: TODO: -* How to make each connection's current file visible to the cache? -* How does the consumer task tell the Client to update the current file? * Does the consumer need to be async? Or can it be a plain thread? * Need a new message saying reload the TOC. diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index fce5e9ea..a3ee0a04 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -26,7 +26,7 @@ use std::{ fs::Metadata, mem, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, PoisonError}, thread, time::Duration, }; @@ -80,6 +80,9 @@ pub struct Cache { /// This project's watcher, or `None` for a single-file "project" or if the /// watcher couldn't start. Dropping it stops the watch. _watcher: Option>, + /// The connections whose current file lies in this project, keyed by + /// connection id; each entry is owned by a `ConnectionRegistration`. + connections: HashMap, // TODO: search engine data storage. Search fields: target ID, contents, // file name. Perhaps [Tantivy](https://docs.rs/tantivy/latest/tantivy/)? } @@ -247,7 +250,8 @@ pub(super) struct FragmentFact { pub(super) struct CommitOutcome { /// Files (other than the committed file) whose rendered output is /// invalidated by this commit; their status has already been set to - /// `Outdated`. The caller should schedule them for reprocessing. + /// `Outdated`, and each connection showing one of them has been notified. + /// The caller should schedule the rest for reprocessing. pub(super) outdated: HashSet, } @@ -299,6 +303,38 @@ pub(super) enum ConsumerPathState { WalkFinished } +// ### Connections +// +// A connection's translation task owns its current file; the cache keeps only +// a copy, refreshed by the task each time that file changes. See +// `Connections` in the cache spec. +/// One connection whose current file lies in this project. +struct ConnectionView { + /// The file shown in the connection's Client, canonicalized to match the + /// paths the cache stores. + current_file: PathBuf, + /// Where to notify the connection. This is unbounded, since the cache lock + /// is held while sending. + notify_tx: UnboundedSender, +} + +/// Notifications the cache sends to a registered connection. +#[derive(Debug, PartialEq, Eq)] +pub enum CacheNotification { + /// A change elsewhere in the project outdated the rendered output of the + /// connection's current file, which the connection should re-translate. + CurrentFileOutdated, +} + +/// Lists a connection in `Cache::connections` for as long as this value lives; +/// dropping it removes the listing. +pub struct ConnectionRegistration { + /// The cache the connection is listed in. + cache: Arc>, + /// The connection's key in `Cache::connections`. + connection_id: String, +} + // Code // ---- impl Cache { @@ -335,6 +371,7 @@ impl Cache { root, found_files: found_rx, _watcher, + connections: HashMap::new(), } } @@ -599,9 +636,7 @@ impl Cache { // A file never outdates itself: its rendered output was just produced // from the state committed here. outdated.remove(path); - for outdated_path in &outdated { - self.set_status(outdated_path, FileStatus::Outdated); - } + self.mark_outdated(&outdated); CommitOutcome { outdated } } @@ -708,8 +743,8 @@ impl Cache { id: &str, // The fragment's code/doc block content, rendered as HTML. content: String, - // The files (other than `path`) marked outdated by this change; their - // status has already been set to `Outdated`. + // The files (other than `path`) marked outdated by this change, as + // described by `CommitOutcome::outdated`. ) -> HashSet { // Apply the new content first, so that no borrow of `self.files` // outlives the check; report whether it differed from the old content. @@ -734,10 +769,32 @@ impl Cache { // Gather elements in the fragment's own file are updated by the caller // in the same processing pass. outdated.remove(path); - for outdated_path in &outdated { + self.mark_outdated(&outdated); + outdated + } + + /// Mark files outdated, then notify each connection showing one of them. + /// The connection re-translates that file itself, from its IDE's contents, + /// which may be newer than the file on disk. + fn mark_outdated( + &mut self, + // The files to mark. + outdated: &HashSet, + ) { + for outdated_path in outdated { self.set_status(outdated_path, FileStatus::Outdated); } - outdated + // TODO: also notify every connection when the project's TOC is outdated, + // once the Client supports a message to reload its TOC. + for connection in self.connections.values() { + if outdated.contains(&connection.current_file) { + // A send fails only once the connection's translation task has + // exited, leaving nothing to notify. + let _ = connection + .notify_tx + .send(CacheNotification::CurrentFileOutdated); + } + } } /// Look up an id, returning the target or fragment it names along with the @@ -853,6 +910,48 @@ impl IdState { } } +impl ConnectionRegistration { + /// List a connection in a project's cache. Drop any earlier registration of + /// this connection first: dropping it afterward would remove this listing, + /// since both share one key. + #[must_use] + pub fn new( + // The cache of the project containing `current_file`. + cache: Arc>, + // The connection's id, unique among all connections. + connection_id: String, + // The file shown in the connection's Client, as a canonicalized + // absolute path. + current_file: PathBuf, + // Where the cache sends this connection's notifications. + notify_tx: UnboundedSender, + ) -> Self { + cache.lock().unwrap().connections.insert( + connection_id.clone(), + ConnectionView { + current_file, + notify_tx, + }, + ); + ConnectionRegistration { + cache, + connection_id, + } + } +} + +impl Drop for ConnectionRegistration { + fn drop(&mut self) { + // Tolerate a poisoned lock: this may run while unwinding from another + // panic, where a second panic aborts the process. + self.cache + .lock() + .unwrap_or_else(PoisonError::into_inner) + .connections + .remove(&self.connection_id); + } +} + /// Diff a file's old and new gather elements, reporting the ids whose rendered /// backlinks changed as a result -- the reverse edge described in /// `commit_file`, whose documentation gives the rule applied here. The file @@ -1001,13 +1100,15 @@ mod tests { use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, + sync::{Arc, Mutex}, }; use test_utils::prep_test_dir; - use tokio::sync::mpsc; + use tokio::sync::mpsc::{self, error::TryRecvError}; use super::{ - Cache, FileFacts, FileStatus, FragmentFact, IdResolution, IdState, TargetFact, walk_project, + Cache, CacheNotification, ConnectionRegistration, FileFacts, FileStatus, FragmentFact, + IdResolution, IdState, TargetFact, walk_project, }; // ### Test helpers @@ -1601,6 +1702,51 @@ mod tests { assert!(outdated.is_empty()); } + // ### Connection tests + // + // Verify that a registered connection is notified only when its current + // file is outdated, and not after its registration is dropped. + #[test] + fn test_connection_notification() { + let cache = Arc::new(Mutex::new(Cache::default())); + let a = PathBuf::from("a.md"); + let b = PathBuf::from("b.md"); + cache + .lock() + .unwrap() + .commit_file(&b, None, facts_xref("foo")); + let (notify_tx, mut notify_rx) = mpsc::unbounded_channel(); + let registration = + ConnectionRegistration::new(cache.clone(), "conn".to_string(), b.clone(), notify_tx); + + // Defining the id `b` references outdates `b`. + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Foo!")); + assert_eq!( + notify_rx.try_recv(), + Ok(CacheNotification::CurrentFileOutdated) + ); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + + // Recommitting `b` itself outdates nothing. + cache + .lock() + .unwrap() + .commit_file(&b, None, facts_xref("foo")); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + + // Dropping the registration drops the cache's only sender. + drop(registration); + assert!(cache.lock().unwrap().connections.is_empty()); + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Bar!")); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Disconnected)); + } + // ### Walker tests // // Verify that the walker finds every file in a nested tree except those diff --git a/server/src/translation.rs b/server/src/translation.rs index 823f854d..ff8836ee 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -223,7 +223,7 @@ use regex::Regex; use tokio::{ fs::File, select, - sync::mpsc::{self, Receiver, Sender}, + sync::mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}, }; // ### Local @@ -233,8 +233,10 @@ use crate::{ processing::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString, UNICODE_CURSOR_MARKER, - byte_index_of, cache::CacheMap, codechat_for_web_to_source, diff_code_mirror_doc_blocks, - diff_str, doc_block_html_to_markdown, minify, remove_tinymce_data, + byte_index_of, + cache::{CacheMap, CacheNotification, ConnectionRegistration}, + codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str, + doc_block_html_to_markdown, find_path_to_toc, minify, project_cache, remove_tinymce_data, source_to_codechat_for_web_string, transform_html, }, queue_send, queue_send_func, @@ -242,8 +244,9 @@ use crate::{ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, MESSAGE_ID_INCREMENT, ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, ResultErrTypes, ResultOkTypes, SimpleHttpResponse, SimpleHttpResponseError, - UpdateMessageContents, WebAppState, WebsocketQueues, file_to_response, log_capture_event, - path_to_url, send_response, try_canonicalize, try_read_as_text, url_to_path, + UpdateMessageContents, WebAppState, WebsocketQueues, canonicalize, file_to_response, + log_capture_event, path_to_url, send_response, try_canonicalize, try_read_as_text, + url_to_path, }, }; @@ -394,6 +397,7 @@ pub fn create_translation_queues( struct TranslationTask { // These parameters are passed to us. app_state: WebAppState, + connection_id: String, connection_id_raw: String, prefix: &'static [&'static str], allow_source_diffs: bool, @@ -405,8 +409,15 @@ struct TranslationTask { cache: CacheMap, // These parameters are internal state. - /// The file currently loaded in the Client. + /// The file currently loaded in the Client; change it only through + /// `set_current_file`. current_file: PathBuf, + /// Lists this connection with the cache of the project containing + /// `current_file`, if any. + cache_registration: Option, + /// The channel on which that cache notifies this connection. + cache_notify_tx: UnboundedSender, + cache_notify_rx: UnboundedReceiver, /// A map of `LoadFile` requests sent to the IDE, awaiting its response. load_file_requests: HashMap, /// The id for messages created by the server. Leave space for a server @@ -477,9 +488,11 @@ pub async fn translation_task( .unwrap() .insert(connection_id.clone(), from_http_tx); + let (cache_notify_tx, cache_notify_rx) = mpsc::unbounded_channel(); let mut continue_loop = true; let mut tt = TranslationTask { app_state: app_state.clone(), + connection_id: connection_id.clone(), connection_id_raw, prefix, allow_source_diffs, @@ -490,6 +503,9 @@ pub async fn translation_task( from_http_rx, cache: app_state.cache.clone(), current_file: PathBuf::new(), + cache_registration: None, + cache_notify_tx, + cache_notify_rx, load_file_requests: HashMap::new(), id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, source_code: String::new(), @@ -557,14 +573,7 @@ pub async fn translation_task( path_to_url(&tt.prefix_str, Some(&tt.connection_id_raw), &clean_file_path), Some(true) ) })); - // Store the canonicalized path, not the - // path the IDE sent: the comparisons - // against `current_file` below assume every - // path has one spelling. - tt.current_file = clean_file_path; - // Since this is a new file, mark it as - // unsent in full. - tt.sent_full = false; + tt.set_current_file(clean_file_path); } Err(err) => { error!("{err:?}"); @@ -680,10 +689,7 @@ pub async fn translation_task( id: client_message.id, message: EditorMessageContents::CurrentFile(file_path_string.to_string(), Some(is_text)) })); - tt.current_file = file_path; - // Since this is a new file, the full text - // hasn't been sent yet. - tt.sent_full = false; + tt.set_current_file(file_path); Ok(()) } } @@ -697,6 +703,12 @@ pub async fn translation_task( } }, + // Handle notifications from the cache of the current file's + // project. + Some(cache_notification) = tt.cache_notify_rx.recv() => match cache_notification { + CacheNotification::CurrentFileOutdated => continue_loop = tt.re_translate_current_file().await, + }, + else => break } } @@ -1209,6 +1221,83 @@ impl TranslationTask { } } + /// Determine if a re-translation of the current file differs from the + /// Client's contents, `self.code_mirror_doc` / `self.code_mirror_doc_blocks`. + /// Note that strict HTML comparison fails, since TinyMCE modifies + /// whitespace and some characters; for example, `👨‍👦` becomes `👨‍👦` + /// after processing by TinyMCE. Therefore, HTML processed by TinyMCE needs + /// to be normalized before it can be compared. + /// + /// 1. Document-only: HTML is stored in `.doc`; ignore `.doc_blocks`. + /// 2. Normal mode: HTML is stored in `.doc_blocks`; perform a binary + /// compare of `.doc`, then an HTML comparison of `.doc_blocks`. + /// + /// It looks like comparing HTML is risky, since TinyMCE (or something) + /// store emojis differently. Perhaps we need to compare Markdown instead of + /// HTML? Or HTML after processing? The second seems easier. + fn re_translation_differs( + &self, + // The current file's metadata. + metadata: &SourceFileMetadata, + // The re-translation. + code_mirror_translated: &CodeMirror, + ) -> bool { + if metadata.mode == MARKDOWN_MODE { + !compare_html(&code_mirror_translated.doc, &self.code_mirror_doc) + } else { + code_mirror_translated.doc != self.code_mirror_doc + || !doc_blocks_compare( + &code_mirror_translated.doc_blocks, + self.code_mirror_doc_blocks.as_ref().unwrap(), + ) + } + } + + /// Send the Client a re-translation of the current file as a diff against + /// its contents, then record the result as the Client's contents. The + /// Client ignores a re-translation while its own edits are unsent (see + /// `setContent` in `CodeChatEditor.mts`) or in flight (see the `Update` + /// handler in `CodeChatEditorFramework.mts`); `client_update` re-translates + /// those edits once they arrive. + async fn send_re_translation( + &mut self, + // The current file's path, spelled as in earlier updates to the Client, + // which ignores an update for any other path. + file_path: String, + // The current file's metadata. + metadata: SourceFileMetadata, + // The version of the Client's contents which the diff applies to. + before_version: f64, + // The version of the Client's contents after applying the diff. + version: f64, + // The re-translation. + code_mirror_translated: CodeMirror, + // False if the Client's queue is closed. + ) -> bool { + let client_contents = + self.diff_code_mirror(metadata, before_version, version, &code_mirror_translated); + debug!( + "Sending re-translation update id = {} back to the Client.", + self.id + ); + queue_send_func!(self.to_client_tx.send(EditorMessage { + id: self.id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path, + // Don't change the current position, since the Client editing + // position should be left undisturbed. + cursor_position: None, + scroll_position: None, + is_re_translation: true, + contents: Some(client_contents), + }) + })); + self.id += MESSAGE_ID_INCREMENT; + self.code_mirror_doc = code_mirror_translated.doc; + self.code_mirror_doc_blocks = Some(code_mirror_translated.doc_blocks); + true + } + #[allow(clippy::too_many_lines)] async fn client_update(&mut self, client_message: EditorMessage) -> bool { let EditorMessageContents::Update(update_message_contents) = client_message.message else { @@ -1251,7 +1340,12 @@ impl TranslationTask { let mut cfw_version = cfw.version; // Translate back to the Client to see if there are - // any changes after this conversion. + // any changes after this conversion, such as line + // wrapping in doc blocks which changes line + // numbering, creation of a new doc block from + // previous code block text, insertion of math, or + // updates from future document intelligence such as + // renamed headings. if let Ok(ccfws) = source_to_codechat_for_web_string( &new_source_code, &clean_file_path, @@ -1262,88 +1356,29 @@ impl TranslationTask { && let CodeMirrorDiffable::Plain(code_mirror_translated) = ccfw.source && self.sent_full + && self + .re_translation_differs(&cfw.metadata, &code_mirror_translated) { - // Determine if the re-translation includes - // changes (such as line wrapping in doc blocks - // which changes line numbering, creation of a - // new doc block from previous code block text, - // insertion of math, or updates from future - // document intelligence such as renamed - // headings, etc.). Note that strict HTML - // comparison fails, since TinyMCE modifies - // whitespace and some characters; for example, - // `👨‍👦` becomes `👨‍👦` after processing by - // TinyMCE. Therefore, HTML processed by TinyMCE - // needs to be normalized before it can be - // compared. - // - // 1. Document-only: HTML is stored in `.doc`; - // ignore `.doc_blocks`. - // 2. Normal mode: HTML is stored in - // `.doc_blocks`; perform a binary compare of - // `.doc`, then an HTML comparison of - // `.doc_blocks`. - // - // It looks like comparing HTML is risky, since - // TinyMCE (or something) store emojis - // differently. Perhaps we need to compare - // Markdown instead of HTML? Or HTML after - // processing? The second seems easier. - let is_markdown_mode = cfw.metadata.mode == MARKDOWN_MODE; - if (is_markdown_mode - && !compare_html( - &code_mirror_translated.doc, - &self.code_mirror_doc, - )) - || (!is_markdown_mode - && (code_mirror_translated.doc != self.code_mirror_doc - || !doc_blocks_compare( - &code_mirror_translated.doc_blocks, - self.code_mirror_doc_blocks.as_ref().unwrap(), - ))) - { - // Use a whole number to avoid encoding - // differences with fractional values. - // Precision loss from the u64 -> f64 cast - // is fine, since we just need a unique-ish - // version number. - cfw_version = { - #[allow(clippy::cast_precision_loss)] - let v = random::() as f64; - v - }; - // The Client needs an update. - let client_contents = self.diff_code_mirror( + // Use a whole number to avoid encoding + // differences with fractional values. Precision + // loss from the u64 -> f64 cast is fine, since + // we just need a unique-ish version number. + cfw_version = { + #[allow(clippy::cast_precision_loss)] + let v = random::() as f64; + v + }; + if !self + .send_re_translation( + update_message_contents.file_path, cfw.metadata.clone(), cfw.version, cfw_version, - &code_mirror_translated, - ); - debug!( - "Sending re-translation update id = {} back to the Client.", - self.id - ); - queue_send_func!(self.to_client_tx.send(EditorMessage { - id: self.id, - message: EditorMessageContents::Update( - UpdateMessageContents { - file_path: update_message_contents.file_path, - // Don't change the current position, since - // the Client editing position should be - // left undisturbed. - cursor_position: None, - scroll_position: None, - is_re_translation: true, - contents: Some(client_contents), - } - ) - })); - self.id += MESSAGE_ID_INCREMENT; - // Update with what was just sent to the - // client. - self.code_mirror_doc = code_mirror_translated.doc; - self.code_mirror_doc_blocks = - Some(code_mirror_translated.doc_blocks); + code_mirror_translated, + ) + .await + { + return false; } } // Correct EOL endings for use with the IDE. @@ -1504,6 +1539,87 @@ impl TranslationTask { true } + + /// Change the file loaded in the Client, moving this connection's cache + /// registration to the project containing the new file. + fn set_current_file( + &mut self, + // The new current file. + current_file: PathBuf, + ) { + // Store the canonicalized path: the comparisons against `current_file` + // assume every path has one spelling, which is also the spelling the + // cache uses. A path from the IDE is already canonical, but one from + // the Client may not be. As in hydration, fall back to the path as + // given if it doesn't exist on disk. + self.current_file = canonicalize(¤t_file).unwrap_or(current_file); + // Since this is a new file, the full text hasn't been sent yet. + self.sent_full = false; + // Drop the old registration first; see `ConnectionRegistration::new`. + self.cache_registration = None; + self.cache_registration = find_path_to_toc(&self.current_file).map(|path_to_toc| { + ConnectionRegistration::new( + project_cache(&self.cache, &self.current_file, &path_to_toc), + self.connection_id.clone(), + self.current_file.clone(), + self.cache_notify_tx.clone(), + ) + }); + } + + /// Re-translate the current file after its project's cache reports that + /// its rendered output is outdated -- because another connection changed a + /// target this file cross-references, for example -- then send any change + /// to the Client. The source is unchanged, so the IDE isn't updated. + async fn re_translate_current_file( + &mut self, + // False if the Client's queue is closed. + ) -> bool { + // One translation elsewhere may send several notifications, such as one + // per changed fragment; one re-translation covers them all. + while self.cache_notify_rx.try_recv().is_ok() {} + // Until the Client has the full text, there's nothing to diff against; + // the full text, once sent, is translated with the current cache. + if !self.sent_full { + return true; + } + // Translate the IDE's contents, which may be newer than the file on + // disk; see `ide_update` for the EOL handling. + match source_to_codechat_for_web_string( + &self.source_code.replace("\r\n", "\n"), + &self.current_file, + self.version, + false, + &self.cache, + ) { + Ok((TranslationResultsString::CodeChat(ccfw), _)) => { + let CodeMirrorDiffable::Plain(code_mirror_translated) = ccfw.source else { + panic!("Unexpected diff value."); + }; + if self.re_translation_differs(&ccfw.metadata, &code_mirror_translated) { + // Keep the version: the source is unchanged, and the IDE, + // which receives nothing, must still agree with the Client + // on the version. + return self + .send_re_translation( + self.current_file.to_str().expect("Since the path started as a string, assume it losslessly translates back to a string.").to_string(), + ccfw.metadata, + self.version, + self.version, + code_mirror_translated, + ) + .await; + } + } + // Other file types have no content which depends on the cache. + Ok(_) => {} + Err(err) => error!( + "Unable to re-translate {}: {err}", + self.current_file.display() + ), + } + true + } } // Given a string using LF-only (Unix style) line endings, convert it to CRLF diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/test.py b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/test.py new file mode 100644 index 00000000..e8d26a43 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/test.py @@ -0,0 +1 @@ +# See . diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md new file mode 100644 index 00000000..39cbddf4 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md @@ -0,0 +1 @@ +

Foo

From 2c75efb4b18e351f7e14dce5e7de7f6e0d0432b4 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 2 Oct 2026 07:07:31 -0500 Subject: [PATCH 05/14] Fix: set sent_full only when the current file's Update is sent. Previously, any LoadFile result containing contents set sent_full, including the TOC's and those of other supporting files. A result arriving before the current file's enabled diffs against the previous file's contents. Conversely, a current file read from disk (because the IDE lacks it) never set sent_full, so diffs and re-translations to the Client were skipped even though the Client had the full text. Co-Authored-By: Claude Opus 5.5 --- extensions/standalone/src/filewatcher.rs | 17 ++++++++++++----- server/src/ide/vscode/tests.rs | 18 ++++++++---------- server/src/translation.rs | 12 +++++++++--- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/extensions/standalone/src/filewatcher.rs b/extensions/standalone/src/filewatcher.rs index 36b2aed4..6e857d28 100644 --- a/extensions/standalone/src/filewatcher.rs +++ b/extensions/standalone/src/filewatcher.rs @@ -786,8 +786,8 @@ mod tests { }; use code_chat_editor::{ processing::{ - CodeChatForWeb, CodeMirror, CodeMirrorDiffable, SourceFileMetadata, - source_to_codechat_for_web, + CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, + StringDiff, source_to_codechat_for_web, }, webserver::{ EditorMessage, EditorMessageContents, INITIAL_CLIENT_MESSAGE_ID, @@ -1157,7 +1157,9 @@ mod tests { let mut s = fs::read_to_string(&file_path).unwrap(); assert_eq!(s, "testing()"); - // 8. Change this file and verify that this produces an update. + // 8. Change this file and verify that this produces an update. Since + // the Client has the file's full text, this is a diff against the + // contents and version it sent in step 7. // // Message ids: IDE - 1->2, Server - 3, Client - 7. s.push_str("123"); @@ -1187,9 +1189,14 @@ mod tests { metadata: SourceFileMetadata { mode: "python".to_string(), }, - source: CodeMirrorDiffable::Plain(CodeMirror { - doc: "testing()123".to_string(), + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: vec![StringDiff { + from: 0, + to: Some(9), + insert: "testing()123".to_string(), + }], doc_blocks: vec![], + version: 2.0, }), version: msg.1.contents.as_ref().unwrap().version, }), diff --git a/server/src/ide/vscode/tests.rs b/server/src/ide/vscode/tests.rs index 5ad93a2f..8b57f466 100644 --- a/server/src/ide/vscode/tests.rs +++ b/server/src/ide/vscode/tests.rs @@ -1493,8 +1493,8 @@ async fn test_vscode_ide_websocket10() { } ); - // Fetch the current file, which is open in the IDE. (Only when the IDE - // supplies the current file's contents does the Server diff against them.) + // Fetch the current file, which the IDE doesn't have, so the Server reads + // it from disk. let test_dir_thread = test_dir.clone(); let join_handle = thread::spawn(move || { assert_eq!( @@ -1515,17 +1515,15 @@ async fn test_vscode_ide_websocket10() { &mut ws_ide, &EditorMessage { id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, - message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(Some(( - fs::read_to_string(&file_path_temp).unwrap(), - 1.0, - ))))), + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), }, ) .await; join_handle.join().unwrap(); // The cross-reference can't be resolved yet, since the TOC hasn't been - // translated. + // translated. Lacking a version from the IDE, the Server keeps its initial + // version. assert_eq!( read_message(&mut ws_client).await, EditorMessage { @@ -1549,7 +1547,7 @@ async fn test_vscode_ide_websocket10() { contents: r#"

See id "foo" not found."#.to_string(), }], }), - version: 1.0, + version: 0.0, }), }), } @@ -1629,9 +1627,9 @@ async fn test_vscode_ide_websocket10() { }], } )], - version: 1.0, + version: 0.0, }), - version: 1.0, + version: 0.0, }), }), } diff --git a/server/src/translation.rs b/server/src/translation.rs index ff8836ee..a7ec9310 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -941,9 +941,6 @@ impl TranslationTask { { self.version = new_version; } - // The IDE just sent the full contents; we're sending full - // contents to the Client. - self.sent_full = true; ( file_to_response( &http_request, @@ -1009,6 +1006,15 @@ impl TranslationTask { message: EditorMessageContents::Update(update) })); self.id += MESSAGE_ID_INCREMENT; + // The Client now has the current file's full contents. Set this + // only here: results of other `LoadFile` requests, such as the + // TOC's, may arrive before the current file's and must not enable + // diffs against the previous file's contents. If these contents + // came from disk because the IDE lacks the file, diffs to the IDE + // are still safe: VSCode rejects any update to a document it + // doesn't have open, and the standalone file watcher, which never + // has the file, receives no diffs (see `allow_source_diffs`). + self.sent_full = true; } debug!("Sending HTTP response."); if let Err(err) = http_request.response_queue.send(simple_http_response) { From d009b4ad882fb98cd50526acb3f3f952aee89194 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 2 Oct 2026 09:15:16 -0500 Subject: [PATCH 06/14] Fix: Client never applies a diff while dirty; reply to skipped re-translations. In document-only mode, a dirty Client applied incoming diffs to its copy of the text from before its edits. If it had ignored a re-translation, that copy no longer matched the text the Server diffed against, corrupting the result. Now a dirty Client reports OutOfSync for any diff, as normal mode already did, so the IDE resends the full text. The Client also replies to a re-translation it skips because its own edit is in flight. Previously it sent nothing, so the Server reported a timeout error, which the IDE then showed. Co-Authored-By: Claude Opus 5.5 --- client/src/CodeChatEditor.mts | 50 ++++++++++++-------------- client/src/CodeChatEditorFramework.mts | 9 +++++ 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index e6d2a280..055f63a3 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -263,13 +263,9 @@ const _openLp = async ( // changes due to re-translation, not due to updates to IDE content. // 2. If this is the full text, discard changes made in the Client since the // last autosave, overwriting them with the provided update. - // 3. If this is a diff: - // 1. In document-only mode, we have a backup copy of the full text - // before it was modified by the Client. Apply the diff to this, - // overwriting changes made in the Client. - // 2. In normal mode, we don't have a backup copy of the full text. - // Report an `OutOfSync` error, which causes the IDE to send the full - // text which will then overwrite changes made in the Client. + // 3. If this is a diff, report an `OutOfSync` error, which causes the IDE + // to send the full text which will then overwrite changes made in the + // Client. if (getIsDirty() && isReTranslation) { consoleLog(`Ignoring re-translation because Client is dirty.`); return; @@ -297,7 +293,18 @@ const _openLp = async ( const source = codechatForWeb["source"]; const codechatBody = document.getElementById("CodeChat-body"); assert(codechatBody instanceof HTMLDivElement); - if (isDocOnly()) { + if (getIsDirty() && "Diff" in source) { + // Send an `OutOfSync` response, so that the IDE will send the full + // text to overwrite these changes with. + webSocketComm().sendResult( + // Pick a rarely-used ID, since we're not responding to a + // specific message. + 0, + // There's not a version that matters. TODO: replace this with a + // more suitable error. + { OutOfSync: [0, 0] }, + ); + } else if (isDocOnly()) { // Per the // [docs](https://docs.mathjax.org/en/latest/web/typeset.html#updating-previously-typeset-content), // "If you modify the page to remove content that contains typeset @@ -371,26 +378,13 @@ const _openLp = async ( await mathJaxTypeset(codechatBody); scrollToLine(cursorPosition, scrollLine); } else { - if (getIsDirty() && "Diff" in source) { - // Send an `OutOfSync` response, so that the IDE will send the - // full text to overwrite these changes with. - webSocketComm().sendResult( - // Pick a rarely-used ID, since we're not responding to a - // specific message. - 0, - // There's not a version that matters. TODO: replace this - // with a more suitable error. - { OutOfSync: [0, 0] }, - ); - } else { - await codeMirrorLoad( - codechatBody, - codechatForWeb, - [], - cursorPosition, - scrollLine, - ); - } + await codeMirrorLoad( + codechatBody, + codechatForWeb, + [], + cursorPosition, + scrollLine, + ); } } finally { // Use a `finally` block to ensure the cleanup code always runs. diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index fb2b021c..4a60dd84 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -186,9 +186,18 @@ class WebSocketComm { this.version ) { if (currentUpdate.is_re_translation) { + // An edit from this Client, still in + // flight to the Server, supersedes + // this re-translation; the Server + // re-translates that edit when it + // arrives. Reply anyway: the Server + // reports an unanswered message as a + // timeout error, which the IDE then + // shows. consoleLog( `Ignoring out-of-sync re-translation update.`, ); + this.sendResult(id); } else { reportError( `Out of sync: Client version ${this.version} !== incoming version ${contents.source.Diff.version}.`, From 0df83e6beb370437b75cbc0e3daa8c0dd9aca09a Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 2 Oct 2026 19:16:32 -0500 Subject: [PATCH 07/14] Fix: reload, rather than rewrite, a file the browser renders when it's updated. When the IDE updated a file which the Client can't edit, the Framework wrote the new contents into the simple viewer's contents iframe (or the page itself) with document.write. This parsed them as HTML, collapsing a text file's line breaks, and threw for an XML document such as an SVG image. Instead, reload the document showing the file, so the browser renders the IDE's latest contents according to the file's type. Co-Authored-By: Claude Opus 5.5 --- client/src/CodeChatEditorFramework.mts | 39 ++-- server/src/translation.rs | 5 + .../overall_1/test_raw_file_update/test.txt | 2 + .../test_simple_viewer_update/test.txt | 2 + .../test_simple_viewer_update/toc.md | 1 + server/tests/overall/overall_1.rs | 210 +++++++++++++++++- 6 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 server/tests/fixtures/overall/overall_1/test_raw_file_update/test.txt create mode 100644 server/tests/fixtures/overall/overall_1/test_simple_viewer_update/test.txt create mode 100644 server/tests/fixtures/overall/overall_1/test_simple_viewer_update/toc.md diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index 4a60dd84..f97eb555 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -448,8 +448,19 @@ class WebSocketComm { // otherwise, this is `undefined`. const getClient = () => rootIframe?.contentWindow?.CodeChatEditor; -// Assign content to either the Client (if it's loaded) or the webpage (if not) -// in the `rootIframe`. +// Return the iframe with the given id in the page in the `rootIframe`, or +// `undefined` if the page has none. The page belongs to the `rootIframe`'s +// window, whose `HTMLIFrameElement` differs from this window's, so `instanceof` +// can't identify the iframe. +const getPageIframe = (id: string) => { + const element = rootIframe!.contentDocument?.getElementById(id); + return element?.tagName === "IFRAME" + ? (element as HTMLIFrameElement) + : undefined; +}; + +// Show the current file's new contents in the `rootIframe`: in the Client, if +// it's loaded; otherwise, by reloading the document which shows the file. const setContent = async ( contents: CodeChatForWeb, isReTranslation: boolean, @@ -458,18 +469,18 @@ const setContent = async ( ) => { const client = getClient(); if (client === undefined) { - // See if this is the [simple viewer](#Client-simple-viewer). Otherwise, - // it's just the bare document to replace. - const contentsElement = - rootIframe!.contentDocument?.getElementById("CodeChat-contents"); - const cw = - (contentsElement instanceof HTMLIFrameElement - ? contentsElement.contentWindow - : undefined) ?? rootIframe!.contentWindow!; - cw.document.open(); - assert("Plain" in contents.source); - cw.document.write(contents.source.Plain.doc); - cw.document.close(); + // The browser itself renders this file. The page shows it either in the + // contents iframe of the [simple viewer](#Client-simple-viewer) or, + // outside a project, as the page itself. Reload that document, rather + // than writing `contents` to it: the Server serves the IDE's latest + // contents, which the browser renders according to the file's type. + // Writing them instead would parse them as HTML, collapsing a text + // file's line breaks, and would throw for an XML document such as an + // SVG image, since only an HTML document supports `document.open`. + ( + getPageIframe("CodeChat-contents")?.contentWindow ?? + rootIframe!.contentWindow! + ).location.reload(); } else { await rootIframe!.contentWindow!.CodeChatEditor.openLp( contents, diff --git a/server/src/translation.rs b/server/src/translation.rs index a7ec9310..20eed085 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -160,6 +160,11 @@ //! Server ->> Client: Update(String: new Client contents) //! else Main file is not editable //! Server ->> Client: Update(String: new text contents) +//! note over Client: Reload the document showing the main file +//! Client ->> Server: HTTP request(URL of main file) +//! Server ->> IDE: LoadFile(String: path to main file) +//! IDE ->> Server: Response(LoadFile(String: file contents of main file)) +//! Server ->> Client: HTTP response(contents of main file) //! end //! Client ->> IDE: Response(String: OK) //! ``` diff --git a/server/tests/fixtures/overall/overall_1/test_raw_file_update/test.txt b/server/tests/fixtures/overall/overall_1/test_raw_file_update/test.txt new file mode 100644 index 00000000..7d914532 --- /dev/null +++ b/server/tests/fixtures/overall/overall_1/test_raw_file_update/test.txt @@ -0,0 +1,2 @@ +First line +Second line diff --git a/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/test.txt b/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/test.txt new file mode 100644 index 00000000..7d914532 --- /dev/null +++ b/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/test.txt @@ -0,0 +1,2 @@ +First line +Second line diff --git a/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/toc.md b/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/toc.md new file mode 100644 index 00000000..51839166 --- /dev/null +++ b/server/tests/fixtures/overall/overall_1/test_simple_viewer_update/toc.md @@ -0,0 +1 @@ +* [test.txt](test.txt) diff --git a/server/tests/overall/overall_1.rs b/server/tests/overall/overall_1.rs index 36ae242b..f80efe1d 100644 --- a/server/tests/overall/overall_1.rs +++ b/server/tests/overall/overall_1.rs @@ -26,7 +26,10 @@ // ------- // // ### Standard library -use std::{path::PathBuf, time::Duration}; +use std::{ + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; // ### Third-party use dunce::canonicalize; @@ -35,6 +38,7 @@ use pretty_assertions::assert_eq; use thirtyfour::{ By, Key, WebDriver, WebElement, error::WebDriverError, prelude::ElementQueryable, }; +use tokio::time::sleep; // ### Local use crate::common::{ @@ -1105,3 +1109,207 @@ async fn test_client_updates_core( Ok(()) } + +// ### Raw file tests +// +// The browser itself renders a file which the Client can't edit, such as a +// text file: in a project, it shows the file in the simple viewer's contents +// iframe, beside the TOC; otherwise, the page itself shows the file. Verify +// that an IDE edit to such a file reloads the document showing it, which +// leaves the rest of the page in place and renders the file according to its +// type, so that a text file keeps its line breaks. +make_test!(test_simple_viewer_update, test_simple_viewer_update_core); + +async fn test_simple_viewer_update_core( + codechat_server: CodeChatEditorServerLog, + driver: WebDriver, + test_dir: PathBuf, +) -> Result<(), WebDriverError> { + let path = canonicalize(test_dir.join("test.txt")).unwrap(); + let toc_path = canonicalize(test_dir.join("toc.md")).unwrap(); + let mut server_id = load_raw_file(&codechat_server, &path, 6.0).await; + // The simple viewer requests the file's text, for its contents iframe, and + // the TOC, in either order. + let mut requested_paths = vec![]; + for _ in 0..2 { + let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); + assert_eq!(msg.id, server_id); + let (requested_path, is_current) = cast!( + msg.message, + EditorMessageContents::LoadFile, + requested_path, + is_current + ); + assert!(!is_current); + let contents = (requested_path == path).then(|| (RAW_TEXT.to_string(), 1.0)); + codechat_server + .send_result_loadfile(server_id, contents) + .await + .unwrap(); + server_id += MESSAGE_ID_INCREMENT; + requested_paths.push(requested_path); + } + requested_paths.sort(); + let mut expected_paths = vec![path.clone(), toc_path]; + expected_paths.sort(); + assert_eq!(requested_paths, expected_paths); + assert!(wait_for_shown_text(&driver, RAW_TEXT).await); + + // Reloading the contents iframe requests the file's text, though not as + // the current file, since the request is for the iframe's raw contents. + edit_raw_file(&codechat_server, &path, false, server_id).await; + assert!(wait_for_shown_text(&driver, EDITED_RAW_TEXT).await); + + assert_no_more_messages(&codechat_server).await; + + Ok(()) +} + +make_test!(test_raw_file_update, test_raw_file_update_core); + +async fn test_raw_file_update_core( + codechat_server: CodeChatEditorServerLog, + driver: WebDriver, + test_dir: PathBuf, +) -> Result<(), WebDriverError> { + let path = canonicalize(test_dir.join("test.txt")).unwrap(); + let server_id = load_raw_file(&codechat_server, &path, 6.0).await; + assert!(!wait_for_shown_text(&driver, RAW_TEXT).await); + + // Reloading the page requests the current file. + edit_raw_file(&codechat_server, &path, true, server_id).await; + assert!(!wait_for_shown_text(&driver, EDITED_RAW_TEXT).await); + + assert_no_more_messages(&codechat_server).await; + + Ok(()) +} + +// The text of the file in the raw file tests, before and after the IDE edits +// it. +const RAW_TEXT: &str = "First line\nSecond line\n"; +const EDITED_RAW_TEXT: &str = "Third line\nFourth line\n"; + +// Make a file which the Client can't edit the current file. The IDE provides +// its text, `RAW_TEXT`. +async fn load_raw_file( + codechat_server: &CodeChatEditorServerLog, + // The file to load. + path: &Path, + // The id of the `LoadFile` message the Server sends for this file. + server_id: f64, + // The id of the Server's next message. +) -> f64 { + let current_file_id = codechat_server + .send_message_current_file(path.to_str().unwrap().to_string()) + .await + .unwrap(); + let mut expected_messages = ExpectedMessages::new(); + expected_messages.insert( + EditorMessage { + id: current_file_id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + false, + ); + expected_messages.insert( + EditorMessage { + id: server_id, + message: EditorMessageContents::LoadFile(path.to_path_buf(), true), + }, + false, + ); + expected_messages + .assert_all_messages(codechat_server, TIMEOUT) + .await; + codechat_server + .send_result_loadfile(server_id, Some((RAW_TEXT.to_string(), 1.0))) + .await + .unwrap(); + server_id + MESSAGE_ID_INCREMENT +} + +// Replace the text of a file which the Client can't edit with +// `EDITED_RAW_TEXT`, as the IDE does when the user edits it. The Client +// acknowledges the edit, then reloads the document showing the file, which +// requests the file's text from the IDE. +async fn edit_raw_file( + codechat_server: &CodeChatEditorServerLog, + // The file to edit. + path: &Path, + // True if the reload requests the file as the current file. + is_current: bool, + // The id of the `LoadFile` message which the reload produces. + server_id: f64, +) { + let ide_id = codechat_server + .send_message_update_plain( + path.to_str().unwrap().to_string(), + Some((EDITED_RAW_TEXT.to_string(), 2.0)), + None, + None, + ) + .await + .unwrap(); + let mut expected_messages = ExpectedMessages::new(); + expected_messages.insert( + EditorMessage { + id: ide_id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + false, + ); + expected_messages.insert( + EditorMessage { + id: server_id, + message: EditorMessageContents::LoadFile(path.to_path_buf(), is_current), + }, + false, + ); + expected_messages + .assert_all_messages(codechat_server, TIMEOUT) + .await; + codechat_server + .send_result_loadfile(server_id, Some((EDITED_RAW_TEXT.to_string(), 2.0))) + .await + .unwrap(); +} + +// Return the text which the document showing a file the Client can't edit +// displays -- the simple viewer's contents iframe, or else the page itself -- +// along with whether the page has a sidebar. This runs in the Framework's +// document, which outlives the reloads of the documents it inspects. +const SHOWN_FILE_JS: &str = r#" + const page = document.getElementById("CodeChat-iframe").contentDocument; + const contents = page.getElementById("CodeChat-contents"); + const shown = contents === null ? page : contents.contentDocument; + return { + text: shown?.body?.innerText ?? null, + hasSidebar: page.getElementById("CodeChat-sidebar") !== null, + }; +"#; + +// Wait until the document showing a file which the Client can't edit displays +// the provided text. +#[allow(deprecated)] +async fn wait_for_shown_text( + driver: &WebDriver, + // The text to wait for. + text: &str, + // True if the page shows a sidebar beside the file. +) -> bool { + driver.enter_default_frame().await.unwrap(); + let deadline = Instant::now() + TIMEOUT; + loop { + let script_ret = driver.execute(SHOWN_FILE_JS, vec![]).await.unwrap(); + let shown = script_ret.json(); + if shown["text"] == text { + return shown["hasSidebar"].as_bool().unwrap(); + } + assert!( + Instant::now() < deadline, + "Timed out waiting for the text {text:?}; the page shows {shown}." + ); + sleep(Duration::from_millis(100)).await; + } +} From dec5f1c34e20889951cdd5b0fcf589f35c965225 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 2 Oct 2026 19:17:11 -0500 Subject: [PATCH 08/14] Add: update the TOC in the Client's sidebar when it changes. When a commit outdates the project's TOC, the cache now sends TocOutdated to every connection in the project. The connection re-renders the TOC from the text its sidebar last loaded and, if the rendering changed, sends the HTML to the Client in a new UpdateToc message. Editing the TOC itself, from the IDE or the Client, refreshes the sidebar the same way. The Client replaces the sidebar's contents in place, preserving its scroll position and the listener which intercepts clicks on the TOC's links. Co-Authored-By: Claude Opus 5.5 --- client/src/CodeChatEditorFramework.mts | 45 ++ extensions/standalone/src/filewatcher.rs | 1 + server/src/ide/vscode/tests.rs | 424 ++++++++++++++++++ server/src/processing/cache-spec.md | 24 +- server/src/processing/cache.rs | 88 +++- server/src/translation.rs | 212 +++++++-- server/src/webserver.rs | 28 +- .../tests/test_vscode_ide_websocket11/test.py | 1 + .../tests/test_vscode_ide_websocket11/toc.md | 1 + .../tests/test_vscode_ide_websocket12/toc.md | 1 + .../overall/overall_1/test_toc_update/test.py | 1 + .../overall/overall_1/test_toc_update/toc.md | 1 + server/tests/overall/overall_1.rs | 94 ++++ 13 files changed, 868 insertions(+), 53 deletions(-) create mode 100644 server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/test.py create mode 100644 server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/toc.md create mode 100644 server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket12/toc.md create mode 100644 server/tests/fixtures/overall/overall_1/test_toc_update/test.py create mode 100644 server/tests/fixtures/overall/overall_1/test_toc_update/toc.md diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index f97eb555..699d5f9f 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -302,6 +302,17 @@ class WebSocketComm { break; } + case "UpdateToc": { + const html = value as string; + // Execute this after all other messages have been + // processed. + this.promise = this.promise.finally(() => { + setSidebarToc(html); + this.sendResult(id); + }); + break; + } + case "Result": { // If the result has the magic ID, then call a debug // routine. @@ -491,6 +502,40 @@ const setContent = async ( } }; +// Replace the contents of the TOC in the sidebar of the page in the +// `rootIframe` -- the Client or the simple viewer. Replacing them in place, +// rather than reloading the sidebar, preserves its scroll position, along with +// the `navigate` listener which the page attached to the sidebar's window to +// intercept clicks on the TOC's links. +const setSidebarToc = (html: string) => { + const sidebarIframe = getPageIframe("CodeChat-sidebar"); + // A page outside a project has no sidebar. + if (sidebarIframe === undefined) { + return; + } + const replaceToc = () => { + // Until the sidebar loads, its document is the initial `about:blank` + // or a partially-parsed TOC. + const tocDocument = sidebarIframe.contentDocument; + if (tocDocument === null || tocDocument.readyState === "loading") { + return false; + } + const toc = tocDocument.querySelector(".CodeChat-TOC"); + if (toc === null) { + return false; + } + toc.innerHTML = html; + return true; + }; + // The Server sends this only after serving the sidebar its TOC, so a + // sidebar which is still loading shows an older rendering; replace that + // once it loads. Waiting here instead would stall every message behind + // this one if the sidebar never finishes loading. + if (!replaceToc()) { + sidebarIframe.addEventListener("load", replaceToc, { once: true }); + } +}; + // The iframe element which composes this page. let rootIframe: HTMLIFrameElement | undefined; diff --git a/extensions/standalone/src/filewatcher.rs b/extensions/standalone/src/filewatcher.rs index 6e857d28..08fc4de8 100644 --- a/extensions/standalone/src/filewatcher.rs +++ b/extensions/standalone/src/filewatcher.rs @@ -710,6 +710,7 @@ fn processing_task( EditorMessageContents::OpenUrl(_) | EditorMessageContents::Capture(_) | EditorMessageContents::ClientHtml(_) | + EditorMessageContents::UpdateToc(_) | EditorMessageContents::RequestClose => { let err = ResultErrTypes::ClientIllegalMessage; error!("{err:?}"); diff --git a/server/src/ide/vscode/tests.rs b/server/src/ide/vscode/tests.rs index 8b57f466..a78ffed3 100644 --- a/server/src/ide/vscode/tests.rs +++ b/server/src/ide/vscode/tests.rs @@ -1655,6 +1655,430 @@ async fn test_vscode_ide_websocket10() { temp_dir.close().unwrap(); } +/// Have the Client acknowledge a message from the Server, then verify that the +/// Server forwards the acknowledgement to the IDE. +async fn acknowledge( + // The Client's websocket. + ws_client: &mut WebSocketStream, + // The IDE's websocket. + ws_ide: &mut WebSocketStream, + // The id of the message to acknowledge. + id: f64, +) { + let result = EditorMessage { + id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }; + send_message(ws_client, &result).await; + assert_eq!(read_message(ws_ide).await, result); +} + +/// Fetch the TOC as the Client's sidebar does. The IDE reports that it doesn't +/// have the TOC, so the Server reads it from disk. +async fn fetch_sidebar_toc( + // The IDE's websocket. + ws_ide: &mut WebSocketStream, + // The connection whose Client fetches the TOC. + connection_id: &str, + // The directory containing the TOC. + test_dir: &Path, + // The id of the `LoadFile` message the Server sends to the IDE. + load_file_id: f64, + // The body of the response: the page showing the TOC. +) -> String { + let url = format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/toc.md?mode=toc", + test_dir.to_slash().unwrap() + ); + let join_handle = thread::spawn(move || { + let response = minreq::get(url).send().unwrap(); + assert_eq!(response.status_code, 200); + response.as_str().unwrap().to_string() + }); + let em = read_message(ws_ide).await; + let (_path, is_current) = cast!( + em.message, + EditorMessageContents::LoadFile, + path, + is_current + ); + assert!(!is_current); + assert_eq!(em.id, load_file_id); + send_message( + ws_ide, + &EditorMessage { + id: load_file_id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), + }, + ) + .await; + join_handle.join().unwrap() +} + +/// Build an `Update` carrying the full text of a file, as the IDE sends it. +fn ide_update( + // The file's path. + file_path: &str, + // The file's lexer. + mode: &str, + // The file's new text. + doc: &str, + // The file's new version. + version: f64, + // The `Update` message. +) -> EditorMessageContents { + EditorMessageContents::Update(UpdateMessageContents { + file_path: file_path.to_string(), + cursor_position: None, + scroll_position: None, + is_re_translation: false, + contents: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: mode.to_string(), + }, + source: CodeMirrorDiffable::Plain(CodeMirror { + doc: doc.to_string(), + doc_blocks: vec![], + }), + version, + }), + }) +} + +/// Verify that the Server refreshes the TOC in the Client's sidebar when its +/// project's cache reports the TOC outdated: the TOC cross-references a heading +/// in the current file, which the IDE then renames. +#[actix_web::test] +async fn test_vscode_ide_websocket11() { + let connection_id = "test-connection-id11"; + let (temp_dir, test_dir, mut ws_ide, mut ws_client) = prep_test!(connection_id).await; + open_client(&mut ws_ide).await; + + // Load the current file, following the protocol in + // `test_vscode_ide_websocket10`. + let file_path_temp = fs::canonicalize(test_dir.join("test.py")).unwrap(); + let file_path = simplified(&file_path_temp); + let file_path_str = file_path.to_str().unwrap().to_string(); + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile( + format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}", + file_path.to_slash().unwrap() + ), + None, + ), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_ide).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile(file_path_str.clone(), Some(true)) + } + ); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) + } + ); + let test_dir_thread = test_dir.clone(); + let join_handle = thread::spawn(move || { + assert_eq!( + minreq::get(format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/test.py", + test_dir_thread.to_slash().unwrap(), + )) + .send() + .unwrap() + .status_code, + 200 + ); + }); + let em = read_message(&mut ws_ide).await; + cast!(em.message, EditorMessageContents::LoadFile, a, b); + assert_eq!(em.id, INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), + }, + ) + .await; + join_handle.join().unwrap(); + let em = read_message(&mut ws_client).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT, + ) + .await; + + // The sidebar's TOC shows the heading's text. + let toc_page = fetch_sidebar_toc( + &mut ws_ide, + connection_id, + &test_dir, + INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, + ) + .await; + assert_contains!(toc_page, "Foo"); + + // Rename the heading in the IDE. The Client receives the edit... + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_IDE_MESSAGE_ID, + message: ide_update(&file_path_str, "python", "#

Bar

\n", 1.0), + }, + ) + .await; + let em = read_message(&mut ws_client).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_IDE_MESSAGE_ID); + acknowledge(&mut ws_client, &mut ws_ide, INITIAL_IDE_MESSAGE_ID).await; + + // ...then, since the edit outdated the TOC, the TOC's new rendering. + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::UpdateToc( + "" + .to_string() + ), + } + ); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + ) + .await; + + check_logger_errors(0); + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); +} + +/// Verify that editing the TOC, from either the IDE or the Client, refreshes +/// the TOC in the Client's sidebar, unless the edit leaves its rendering +/// unchanged. +#[actix_web::test] +async fn test_vscode_ide_websocket12() { + let connection_id = "test-connection-id12"; + let (temp_dir, test_dir, mut ws_ide, mut ws_client) = prep_test!(connection_id).await; + open_client(&mut ws_ide).await; + + // Make the TOC the current file, following the protocol in + // `test_vscode_ide_websocket10`. + let file_path_temp = fs::canonicalize(test_dir.join("toc.md")).unwrap(); + let file_path = simplified(&file_path_temp); + let file_path_str = file_path.to_str().unwrap().to_string(); + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile( + format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}", + file_path.to_slash().unwrap() + ), + None, + ), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_ide).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::CurrentFile(file_path_str.clone(), Some(true)) + } + ); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) + } + ); + let test_dir_thread = test_dir.clone(); + let join_handle = thread::spawn(move || { + assert_eq!( + minreq::get(format!( + "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/toc.md", + test_dir_thread.to_slash().unwrap(), + )) + .send() + .unwrap() + .status_code, + 200 + ); + }); + let em = read_message(&mut ws_ide).await; + let (_path, is_current) = cast!( + em.message, + EditorMessageContents::LoadFile, + path, + is_current + ); + assert!(is_current); + assert_eq!(em.id, INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), + }, + ) + .await; + join_handle.join().unwrap(); + let em = read_message(&mut ws_client).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 2.0 * MESSAGE_ID_INCREMENT, + ) + .await; + let toc_page = fetch_sidebar_toc( + &mut ws_ide, + connection_id, + &test_dir, + INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, + ) + .await; + assert_contains!(toc_page, "

Contents

"); + + // Edit the TOC in the IDE. The Client receives the edit, then the sidebar's + // new rendering of it. + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_IDE_MESSAGE_ID, + message: ide_update(&file_path_str, "markdown", "# Contents\n\nMore\n", 1.0), + }, + ) + .await; + let em = read_message(&mut ws_client).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_IDE_MESSAGE_ID); + acknowledge(&mut ws_client, &mut ws_ide, INITIAL_IDE_MESSAGE_ID).await; + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::UpdateToc("

Contents

More".to_string()), + } + ); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + ) + .await; + + // An edit which doesn't change the TOC's rendering doesn't refresh the + // sidebar; otherwise, an `UpdateToc` would precede the one checked below. + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_IDE_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: ide_update(&file_path_str, "markdown", "# Contents\n\nMore\n\n", 2.0), + }, + ) + .await; + let em = read_message(&mut ws_client).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_IDE_MESSAGE_ID + MESSAGE_ID_INCREMENT); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_IDE_MESSAGE_ID + MESSAGE_ID_INCREMENT, + ) + .await; + + // Edit the TOC in the Client. The sidebar receives the new rendering, while + // the IDE receives the edit. + send_message( + &mut ws_client, + &EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: file_path_str.clone(), + cursor_position: None, + scroll_position: None, + is_re_translation: false, + contents: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "markdown".to_string(), + }, + source: CodeMirrorDiffable::Plain(CodeMirror { + doc: "

Contents

More and more

".to_string(), + doc_blocks: vec![], + }), + version: 3.0, + }), + }), + }, + ) + .await; + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 5.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::UpdateToc( + "

Contents

More and more".to_string() + ), + } + ); + let em = read_message(&mut ws_ide).await; + cast!(em.message, EditorMessageContents::Update); + assert_eq!(em.id, INITIAL_CLIENT_MESSAGE_ID + MESSAGE_ID_INCREMENT); + let result = EditorMessage { + id: INITIAL_CLIENT_MESSAGE_ID + MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }; + send_message(&mut ws_ide, &result).await; + assert_eq!(read_message(&mut ws_client).await, result); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 5.0 * MESSAGE_ID_INCREMENT, + ) + .await; + + check_logger_errors(0); + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); +} + #[test] fn test_find_eoltypes() { assert_eq!( diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 758b282e..ac5c7e59 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -391,6 +391,29 @@ is unchanged, the re-translation keeps the file's version, and the IDE isn't updated. The Client ignores a re-translation while its own edits are unsent or in flight; those edits are re-translated when they reach the Server. +Every connection in a project also shows the project's TOC in its Client's +sidebar, so whenever a commit marks the TOC outdated, the cache sends +`TocOutdated` to every connection in the project. The connection re-renders the +TOC from the text its sidebar last loaded -- from the IDE if the IDE has the TOC +open, otherwise from disk -- then, if the rendering changed, sends the new HTML +to the Client in an `UpdateToc` message. The Client replaces the contents of its +sidebar in place, which preserves the sidebar's scroll position along with the +listener its page attached to intercept clicks on the TOC's links; reloading the +sidebar would discard both. + +Committing the TOC itself doesn't outdate the TOC, since a commit never outdates +its own file. This matters because each sidebar load translates, and therefore +commits, the TOC: were that commit to outdate the TOC, each refresh would +trigger another. Instead, edits to the TOC reach the sidebar in other ways: + +* When a connection's current file is the TOC, the connection refreshes its + sidebar after each edit to the TOC, whether made in the IDE or the Client. +* TODO: once the consumer task exists, have it notify every connection in the + project when the TOC changes on disk. Since the TOC's text then differs from + the text the sidebar last loaded, this needs a notification other than + `TocOutdated`, prompting the connection to fetch the TOC's new text from its + IDE (with a `LoadFile`), falling back to disk, before re-rendering it. + A registration holds a strong reference to its cache, so the cache outlives its LRU map entry while any connection views a file in its project. TODO: keep the LRU map from evicting a cache with registered connections; until then, the next @@ -413,7 +436,6 @@ Overall shutdown sequence: TODO: * Does the consumer need to be async? Or can it be a plain thread? -* Need a new message saying reload the TOC.


diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index a3ee0a04..e8eceb6c 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -300,7 +300,7 @@ pub(super) enum WatcherPathState { pub(super) enum ConsumerPathState { WatcherPathState(WatcherPathState), WalkStarted(PathBuf), - WalkFinished + WalkFinished, } // ### Connections @@ -324,6 +324,10 @@ pub enum CacheNotification { /// A change elsewhere in the project outdated the rendered output of the /// connection's current file, which the connection should re-translate. CurrentFileOutdated, + /// A change elsewhere in the project outdated the rendered output of the + /// project's TOC, which every connection in the project shows in its + /// sidebar; the connection should re-render it. + TocOutdated, } /// Lists a connection in `Cache::connections` for as long as this value lives; @@ -773,9 +777,10 @@ impl Cache { outdated } - /// Mark files outdated, then notify each connection showing one of them. - /// The connection re-translates that file itself, from its IDE's contents, - /// which may be newer than the file on disk. + /// Mark files outdated, then notify each connection showing one of them: + /// as its current file, or as the TOC in its sidebar, which every + /// connection in the project shows. The connection re-renders that file + /// itself, since the text it shows may be newer than the file on disk. fn mark_outdated( &mut self, // The files to mark. @@ -784,16 +789,20 @@ impl Cache { for outdated_path in outdated { self.set_status(outdated_path, FileStatus::Outdated); } - // TODO: also notify every connection when the project's TOC is outdated, - // once the Client supports a message to reload its TOC. + // The project's root is the directory containing its TOC; see + // `find_path_to_toc`. + let is_toc_outdated = outdated.contains(&self.root.join("toc.md")); for connection in self.connections.values() { + // A send fails only once the connection's translation task has + // exited, leaving nothing to notify. if outdated.contains(&connection.current_file) { - // A send fails only once the connection's translation task has - // exited, leaving nothing to notify. let _ = connection .notify_tx .send(CacheNotification::CurrentFileOutdated); } + if is_toc_outdated { + let _ = connection.notify_tx.send(CacheNotification::TocOutdated); + } } } @@ -1747,6 +1756,69 @@ mod tests { assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Disconnected)); } + // Verify that outdating the TOC notifies every registered connection, since + // each shows the TOC in its sidebar, and that recommitting the TOC itself + // notifies none of them. + #[test] + fn test_toc_notification() { + let cache = Arc::new(Mutex::new(Cache::default())); + // The default cache's root is empty, so this is its TOC. + let toc = PathBuf::from("toc.md"); + let a = PathBuf::from("a.md"); + let b = PathBuf::from("b.md"); + // The TOC cross-references a heading in `a`. + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Foo")); + cache + .lock() + .unwrap() + .commit_file(&toc, None, facts_xref("foo")); + // Register a connection whose current file is `current_file`, returning + // its registration and the receiver of its notifications. + let register = |connection_id: &str, current_file: &PathBuf| { + let (notify_tx, notify_rx) = mpsc::unbounded_channel(); + let registration = ConnectionRegistration::new( + cache.clone(), + connection_id.to_string(), + current_file.clone(), + notify_tx, + ); + (registration, notify_rx) + }; + let (_definer, mut definer_rx) = register("definer", &a); + let (_bystander, mut bystander_rx) = register("bystander", &b); + let (_toc_viewer, mut toc_viewer_rx) = register("toc_viewer", &toc); + + // Renaming the heading outdates the TOC. A connection whose current + // file is the TOC must re-translate it as well. + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Bar")); + for notify_rx in [&mut definer_rx, &mut bystander_rx] { + assert_eq!(notify_rx.try_recv(), Ok(CacheNotification::TocOutdated)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + } + assert_eq!( + toc_viewer_rx.try_recv(), + Ok(CacheNotification::CurrentFileOutdated) + ); + assert_eq!(toc_viewer_rx.try_recv(), Ok(CacheNotification::TocOutdated)); + assert_eq!(toc_viewer_rx.try_recv(), Err(TryRecvError::Empty)); + + // Every sidebar load translates, and therefore recommits, the TOC; this + // must not outdate the TOC again. + cache + .lock() + .unwrap() + .commit_file(&toc, None, facts_xref("foo")); + for notify_rx in [&mut definer_rx, &mut bystander_rx, &mut toc_viewer_rx] { + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + } + } + // ### Walker tests // // Verify that the walker finds every file in a nested tree except those diff --git a/server/src/translation.rs b/server/src/translation.rs index 20eed085..572f0be4 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -172,6 +172,14 @@ //! * If the current file's contents in the Client are edited, the Client sends //! the IDE an `Update` with the revised contents. //! +//! * If a change elsewhere in the project outdates the rendering of the current +//! file or of the project's TOC, the project's cache notifies the Server, +//! which re-renders it and sends any change to the Client: an `Update` for +//! the current file, or an `UpdateToc` for the TOC in the Client's sidebar. +//! The Client replies to each with a `Result`. Editing the TOC itself also +//! sends an `UpdateToc`. See `Connections` in the +//! [cache spec](processing/cache-spec.md). +//! //! * When the PC goes to sleep then wakes up, the IDE client and the Editor //! client both reconnect to the websocket URL containing their assigned ID. //! @@ -215,6 +223,7 @@ use std::{ collections::HashMap, ffi::OsStr, fmt::Debug, + iter, path::{Path, PathBuf}, rc::Rc, sync::LazyLock, @@ -248,7 +257,7 @@ use crate::{ webserver::{ CursorPosition, EditorMessage, EditorMessageContents, INITIAL_MESSAGE_ID, MESSAGE_ID_INCREMENT, ProcessingTaskHttpRequest, ProcessingTaskHttpRequestFlags, - ResultErrTypes, ResultOkTypes, SimpleHttpResponse, SimpleHttpResponseError, + ResultErrTypes, ResultOkTypes, ServedContents, SimpleHttpResponse, SimpleHttpResponseError, UpdateMessageContents, WebAppState, WebsocketQueues, canonicalize, file_to_response, log_capture_event, path_to_url, send_response, try_canonicalize, try_read_as_text, url_to_path, @@ -397,6 +406,18 @@ pub fn create_translation_queues( }) } +/// The project TOC which the Client's sidebar shows, retained so that the +/// sidebar can be updated when the TOC's rendering is outdated. +struct SidebarToc { + /// The TOC's path, as requested by the sidebar. + path: PathBuf, + /// The TOC's text, from the IDE if it has the TOC open, otherwise from + /// disk. + source: String, + /// The HTML the sidebar shows, rendered from `source`. + html: String, +} + /// This holds the state used by the main loop of the translation task; this /// allows factoring out lengthy contents in the loop into subfunctions. struct TranslationTask { @@ -423,6 +444,9 @@ struct TranslationTask { /// The channel on which that cache notifies this connection. cache_notify_tx: UnboundedSender, cache_notify_rx: UnboundedReceiver, + /// The TOC which the sidebar of the current file's page shows; `None` + /// until the sidebar loads it, or if the page has no sidebar. + sidebar_toc: Option, /// A map of `LoadFile` requests sent to the IDE, awaiting its response. load_file_requests: HashMap, /// The id for messages created by the server. Leave space for a server @@ -511,6 +535,7 @@ pub async fn translation_task( cache_registration: None, cache_notify_tx, cache_notify_rx, + sidebar_toc: None, load_file_requests: HashMap::new(), id: INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT, source_code: String::new(), @@ -537,7 +562,8 @@ pub async fn translation_task( EditorMessageContents::Opened(_) | EditorMessageContents::OpenUrl(_) | EditorMessageContents::LoadFile(..) | - EditorMessageContents::ClientHtml(_) => { + EditorMessageContents::ClientHtml(_) | + EditorMessageContents::UpdateToc(_) => { let err = ResultErrTypes::IdeIllegalMessage; error!("{err:?}"); send_response(&tt.to_ide_tx, ide_message.id, Err(err)).await; @@ -620,7 +646,8 @@ pub async fn translation_task( EditorMessageContents::Opened(_) | EditorMessageContents::LoadFile(..) | EditorMessageContents::RequestClose | - EditorMessageContents::ClientHtml(_) => { + EditorMessageContents::ClientHtml(_) | + EditorMessageContents::UpdateToc(_) => { let err = ResultErrTypes::ClientIllegalMessage; error!("{err:?}"); send_response(&tt.to_client_tx, client_message.id, Err(err)).await; @@ -710,9 +737,7 @@ pub async fn translation_task( // Handle notifications from the cache of the current file's // project. - Some(cache_notification) = tt.cache_notify_rx.recv() => match cache_notification { - CacheNotification::CurrentFileOutdated => continue_loop = tt.re_translate_current_file().await, - }, + Some(cache_notification) = tt.cache_notify_rx.recv() => continue_loop = tt.cache_notified(cache_notification).await, else => break } @@ -937,7 +962,7 @@ impl TranslationTask { // determine if this is a PDF file. (TODO: look at the magic number also // -- "%PDF"). let use_pdf_js = http_request.file_path.extension() == Some(OsStr::new("pdf")); - let ((simple_http_response, option_update), file_contents) = match file_contents_option { + let ((simple_http_response, served_contents), file_contents) = match file_contents_option { Some((file_contents, new_version)) => { // Only pay attention to the version if this is an editable // Client file. @@ -992,34 +1017,54 @@ impl TranslationTask { } } }; - if let Some(update) = option_update { - let Some(ref tmp) = update.contents else { - panic!("Contents must always be provided."); - }; - let CodeMirrorDiffable::Plain(ref plain) = tmp.source else { - panic!("Diff not supported."); - }; - self.source_code = file_contents; - self.eol = find_eol_type(&self.source_code); - // We must clone here, since the original is placed in the TX queue. - self.code_mirror_doc.clone_from(&plain.doc); - self.code_mirror_doc_blocks = Some(plain.doc_blocks.clone()); - - debug!("Sending Update from LoadFile to Client, id = {}.", self.id); - queue_send_func!(self.to_client_tx.send(EditorMessage { - id: self.id, - message: EditorMessageContents::Update(update) - })); - self.id += MESSAGE_ID_INCREMENT; - // The Client now has the current file's full contents. Set this - // only here: results of other `LoadFile` requests, such as the - // TOC's, may arrive before the current file's and must not enable - // diffs against the previous file's contents. If these contents - // came from disk because the IDE lacks the file, diffs to the IDE - // are still safe: VSCode rejects any update to a document it - // doesn't have open, and the standalone file watcher, which never - // has the file, receives no diffs (see `allow_source_diffs`). - self.sent_full = true; + match served_contents { + Some(ServedContents::Client(update)) => { + let Some(ref tmp) = update.contents else { + panic!("Contents must always be provided."); + }; + let CodeMirrorDiffable::Plain(ref plain) = tmp.source else { + panic!("Diff not supported."); + }; + self.source_code = file_contents; + self.eol = find_eol_type(&self.source_code); + // We must clone here, since the original is placed in the TX + // queue. + self.code_mirror_doc.clone_from(&plain.doc); + self.code_mirror_doc_blocks = Some(plain.doc_blocks.clone()); + + debug!("Sending Update from LoadFile to Client, id = {}.", self.id); + queue_send_func!(self.to_client_tx.send(EditorMessage { + id: self.id, + message: EditorMessageContents::Update(update) + })); + self.id += MESSAGE_ID_INCREMENT; + // The Client now has the current file's full contents. Set + // this only here: results of other `LoadFile` requests, such as + // the TOC's, may arrive before the current file's and must not + // enable diffs against the previous file's contents. If these + // contents came from disk because the IDE lacks the file, diffs + // to the IDE are still safe: VSCode rejects any update to a + // document it doesn't have open, and the standalone file + // watcher, which never has the file, receives no diffs (see + // `allow_source_diffs`). + self.sent_full = true; + } + // Record the TOC which the sidebar now shows, unless it's the TOC + // of another project -- requested by the sidebar of a page which + // the Client has since left. + Some(ServedContents::Toc(html)) + if http_request + .file_path + .parent() + .is_some_and(|project_root| self.current_file.starts_with(project_root)) => + { + self.sidebar_toc = Some(SidebarToc { + path: http_request.file_path.clone(), + source: file_contents, + html, + }); + } + Some(ServedContents::Toc(_)) | None => {} } debug!("Sending HTTP response."); if let Err(err) = http_request.response_queue.send(simple_http_response) { @@ -1132,6 +1177,9 @@ impl TranslationTask { // Update to the version of the file just // sent. self.version = contents.version; + if !self.refresh_sidebar_if_toc_edited().await { + return false; + } Ok(ResultOkTypes::Void) } // TODO @@ -1421,6 +1469,9 @@ impl TranslationTask { }; self.version = cfw_version; self.source_code = new_source_code_eol; + if !self.refresh_sidebar_if_toc_edited().await { + return false; + } new_cfw } Err(message) => { @@ -1566,6 +1617,8 @@ impl TranslationTask { self.current_file = canonicalize(¤t_file).unwrap_or(current_file); // Since this is a new file, the full text hasn't been sent yet. self.sent_full = false; + // Nor has its page's sidebar, if any, loaded the TOC. + self.sidebar_toc = None; // Drop the old registration first; see `ConnectionRegistration::new`. self.cache_registration = None; self.cache_registration = find_path_to_toc(&self.current_file).map(|path_to_toc| { @@ -1578,6 +1631,34 @@ impl TranslationTask { }); } + /// Respond to notifications from the cache of the current file's project. + /// One translation elsewhere may send several notifications, such as one + /// per changed fragment, so gather all those queued, then re-render each + /// kind of outdated content once. + async fn cache_notified( + &mut self, + // The notification which woke this task. + notification: CacheNotification, + // False if the Client's queue is closed. + ) -> bool { + let mut is_current_file_outdated = false; + let mut is_toc_outdated = false; + let queued = iter::from_fn(|| self.cache_notify_rx.try_recv().ok()); + for notification in iter::once(notification).chain(queued) { + match notification { + CacheNotification::CurrentFileOutdated => is_current_file_outdated = true, + CacheNotification::TocOutdated => is_toc_outdated = true, + } + } + if is_current_file_outdated && !self.re_translate_current_file().await { + return false; + } + if is_toc_outdated && !self.refresh_sidebar_toc().await { + return false; + } + true + } + /// Re-translate the current file after its project's cache reports that /// its rendered output is outdated -- because another connection changed a /// target this file cross-references, for example -- then send any change @@ -1586,9 +1667,6 @@ impl TranslationTask { &mut self, // False if the Client's queue is closed. ) -> bool { - // One translation elsewhere may send several notifications, such as one - // per changed fragment; one re-translation covers them all. - while self.cache_notify_rx.try_recv().is_ok() {} // Until the Client has the full text, there's nothing to diff against; // the full text, once sent, is translated with the current cache. if !self.sent_full { @@ -1631,6 +1709,64 @@ impl TranslationTask { } true } + + /// Re-render the TOC which the Client's sidebar shows, then send the Client + /// the result if it changed. The Client replaces the sidebar's contents in + /// place, which preserves its scroll position. + async fn refresh_sidebar_toc( + &mut self, + // False if the Client's queue is closed. + ) -> bool { + // Until the sidebar loads the TOC, there's nothing to refresh; loading + // it renders the TOC from the current cache. + let Some(sidebar_toc) = &mut self.sidebar_toc else { + return true; + }; + let html = match source_to_codechat_for_web_string( + &sidebar_toc.source.replace("\r\n", "\n"), + &sidebar_toc.path, + // A TOC's HTML doesn't include a version. + 0.0, + true, + &self.cache, + ) { + Ok((TranslationResultsString::Toc(html), _)) => html, + result => { + error!( + "Unable to render the TOC {}: {result:?}", + sidebar_toc.path.display() + ); + return true; + } + }; + if html == sidebar_toc.html { + return true; + } + sidebar_toc.html.clone_from(&html); + debug!("Sending TOC update id = {} to the Client.", self.id); + queue_send_func!(self.to_client_tx.send(EditorMessage { + id: self.id, + message: EditorMessageContents::UpdateToc(html), + })); + self.id += MESSAGE_ID_INCREMENT; + true + } + + /// After an edit to the current file, refresh the Client's sidebar if that + /// file is the TOC it shows. The edit doesn't outdate the TOC in the cache: + /// a file's translation never outdates the file itself. + async fn refresh_sidebar_if_toc_edited( + &mut self, + // False if the Client's queue is closed. + ) -> bool { + match &mut self.sidebar_toc { + Some(sidebar_toc) if sidebar_toc.path == self.current_file => { + sidebar_toc.source.clone_from(&self.source_code); + self.refresh_sidebar_toc().await + } + _ => true, + } + } } // Given a string using LF-only (Unix style) line endings, convert it to CRLF diff --git a/server/src/webserver.rs b/server/src/webserver.rs index f2d3d0eb..add5899d 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -156,6 +156,17 @@ pub enum SimpleHttpResponse { Bin(PathBuf), } +/// What a page served by `file_to_response` shows, which the processing task +/// tracks in order to update that page later. +#[derive(Debug)] +pub enum ServedContents { + /// The page is a Client, which this `Update` populates with the file's + /// contents. + Client(UpdateMessageContents), + /// The page is the TOC shown in a sidebar, which consists of this HTML. + Toc(String), +} + // List all the possible errors when responding to an HTTP request. See // [The definitive guide to error handling in Rust](https://www.howtocodeit.com/articles/the-definitive-guide-to-rust-error-handling). #[derive(Debug, thiserror::Error)] @@ -240,6 +251,10 @@ pub enum EditorMessageContents { /// HTML for the CodeChat Editor Client to display in its built-in browser. /// Valid destinations: IDE. ClientHtml(String), + /// Replace the contents of the TOC in the Client's sidebar with the + /// provided HTML, the TOC re-rendered after a change which outdated it. + /// Valid destinations: Client. + UpdateToc(String), /// Sent when the IDE or Client websocket was closed, indicating that the /// unclosed websocket should be closed as well. Therefore, this message /// will never be received by the IDE or Client. Valid destinations: Server. @@ -935,9 +950,9 @@ pub async fn file_to_response( // The response to send back to the HTTP endpoint. SimpleHttpResponse, // If the response is a Client, also return the appropriate `Update` data to - // populate the Client with the parsed `file_contents`. In all other cases, - // return None. - Option, + // populate the Client with the parsed `file_contents`; if it's a TOC, also + // return the TOC's HTML. In all other cases, return None. + Option, ) { // Use a lossy conversion, since this is UI display, not filesystem access. let file_path = &http_request.file_path; @@ -1105,7 +1120,7 @@ pub async fn file_to_response( "#, )), - None, + Some(ServedContents::Toc(html)), ); } }; @@ -1177,13 +1192,13 @@ pub async fn file_to_response( )), // If this file is editable and is the main file, send an `Update`. The // `simple_http_response` contains the Client. - Some(UpdateMessageContents { + Some(ServedContents::Client(UpdateMessageContents { file_path: file_path.to_string(), cursor_position: None, scroll_position: None, is_re_translation: false, contents: Some(codechat_for_web), - }), + })), ) } @@ -1396,6 +1411,7 @@ pub fn client_websocket( // Check for an invalid message. EditorMessageContents::LoadFile(..) | EditorMessageContents::ClientHtml(_) | + EditorMessageContents::UpdateToc(_) | EditorMessageContents::Closed => { let err = ResultErrTypes::ClientIllegalMessage; error!("{err}"); diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/test.py b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/test.py new file mode 100644 index 00000000..b7e715a6 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/test.py @@ -0,0 +1 @@ +#

Foo

diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/toc.md b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/toc.md new file mode 100644 index 00000000..d6375002 --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket11/toc.md @@ -0,0 +1 @@ +* diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket12/toc.md b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket12/toc.md new file mode 100644 index 00000000..bc3f6dbe --- /dev/null +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket12/toc.md @@ -0,0 +1 @@ +# Contents diff --git a/server/tests/fixtures/overall/overall_1/test_toc_update/test.py b/server/tests/fixtures/overall/overall_1/test_toc_update/test.py new file mode 100644 index 00000000..b7e715a6 --- /dev/null +++ b/server/tests/fixtures/overall/overall_1/test_toc_update/test.py @@ -0,0 +1 @@ +#

Foo

diff --git a/server/tests/fixtures/overall/overall_1/test_toc_update/toc.md b/server/tests/fixtures/overall/overall_1/test_toc_update/toc.md new file mode 100644 index 00000000..d6375002 --- /dev/null +++ b/server/tests/fixtures/overall/overall_1/test_toc_update/toc.md @@ -0,0 +1 @@ +* diff --git a/server/tests/overall/overall_1.rs b/server/tests/overall/overall_1.rs index f80efe1d..de51e73f 100644 --- a/server/tests/overall/overall_1.rs +++ b/server/tests/overall/overall_1.rs @@ -1110,6 +1110,100 @@ async fn test_client_updates_core( Ok(()) } +// ### TOC tests +// +// Verify that the sidebar shows the TOC the Server re-renders after a change +// outdates it -- here, renaming a heading which the TOC cross-references -- and +// that the sidebar updates in place rather than reloading. +make_test!(test_toc_update, test_toc_update_core); + +// Some of the thirtyfour calls are marked as deprecated, though they aren't +// marked that way in the Selenium docs. +#[allow(deprecated)] +async fn test_toc_update_core( + codechat_server: CodeChatEditorServerLog, + driver: WebDriver, + test_dir: PathBuf, +) -> Result<(), WebDriverError> { + let path = canonicalize(test_dir.join("test.py")).unwrap(); + let path_str = path.to_str().unwrap().to_string(); + let server_id = perform_loadfile( + &codechat_server, + &test_dir, + "test.py", + Some(("#

Foo

\n".to_string(), 1.0)), + true, + 6.0, + ) + .await; + + // The TOC shows the heading's text. Mark the sidebar's window, in order to + // detect a reload, which would replace it. + select_codechat_iframe(&driver).await; + let toc_iframe = driver + .query(By::Css("#CodeChat-sidebar")) + .first() + .await + .unwrap(); + toc_iframe.enter_frame().await.unwrap(); + let toc_link = driver + .query(By::Css(".CodeChat-TOC a")) + .first() + .await + .unwrap(); + assert_eq!(toc_link.text().await.unwrap(), "Foo"); + driver + .execute("window.unreloaded = true;", vec![]) + .await + .unwrap(); + + // Rename the heading in the IDE. The Client acknowledges both the edit and + // the TOC update which the edit produces. + let ide_id = codechat_server + .send_message_update_plain( + path_str, + Some(("#

Bar

\n".to_string(), 2.0)), + None, + None, + ) + .await + .unwrap(); + let mut expected_messages = ExpectedMessages::new(); + for id in [ide_id, server_id] { + expected_messages.insert( + EditorMessage { + id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), + }, + false, + ); + } + expected_messages + .assert_all_messages(&codechat_server, TIMEOUT) + .await; + + // The sidebar shows the new text, without having reloaded. + let toc_link = driver + .query(By::Css(".CodeChat-TOC a")) + .first() + .await + .unwrap(); + assert_eq!(toc_link.text().await.unwrap(), "Bar"); + assert_eq!( + driver + .execute("return window.unreloaded === true;", vec![]) + .await + .unwrap() + .json() + .as_bool(), + Some(true) + ); + + assert_no_more_messages(&codechat_server).await; + + Ok(()) +} + // ### Raw file tests // // The browser itself renders a file which the Client can't edit, such as a From b1518b900effddb9697e2eba624aeaf5e245c8e3 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 8 Oct 2026 10:30:33 -0500 Subject: [PATCH 09/14] wip: cache improvements. --- server/src/ide/vscode/tests.rs | 56 +- server/src/processing.rs | 61 +- server/src/processing/cache-spec.md | 264 ++--- server/src/processing/cache.rs | 1641 +++++++++++++++++++++++---- server/src/processing/tests.rs | 56 + server/src/translation.rs | 21 +- server/src/webserver.rs | 9 +- 7 files changed, 1676 insertions(+), 432 deletions(-) diff --git a/server/src/ide/vscode/tests.rs b/server/src/ide/vscode/tests.rs index a78ffed3..66d8df54 100644 --- a/server/src/ide/vscode/tests.rs +++ b/server/src/ide/vscode/tests.rs @@ -1442,9 +1442,11 @@ async fn test_vscode_ide_websocket9() { temp_dir.close().unwrap(); } -/// Verify that the Server re-translates the current file when its project's -/// cache reports it's outdated: the current file cross-references an id which -/// only the TOC defines, so loading the TOC resolves the cross-reference. +/// Verify that the Server re-translates the current file when a change to +/// another file on disk outdates it. The current file cross-references an id +/// which no file defines until the test rewrites the TOC on disk to define it. +/// The project's watcher reports this change, so its consumer re-translates the +/// TOC, outdating the current file. #[actix_web::test] async fn test_vscode_ide_websocket10() { let connection_id = "test-connection-id10"; @@ -1521,9 +1523,8 @@ async fn test_vscode_ide_websocket10() { .await; join_handle.join().unwrap(); - // The cross-reference can't be resolved yet, since the TOC hasn't been - // translated. Lacking a version from the IDE, the Server keeps its initial - // version. + // No file defines `foo` yet, so the cross-reference can't be resolved. + // Lacking a version from the IDE, the Server keeps its initial version. assert_eq!( read_message(&mut ws_client).await, EditorMessage { @@ -1568,32 +1569,17 @@ async fn test_vscode_ide_websocket10() { } ); - // Fetch the TOC, which defines the id the current file cross-references. - let test_dir_thread = test_dir.clone(); - let join_handle = thread::spawn(move || { - assert_eq!( - minreq::get(format!( - "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/toc.md?mode=toc", - test_dir_thread.to_slash().unwrap() - )) - .send() - .unwrap() - .status_code, - 200 - ); - }); - let em = read_message(&mut ws_ide).await; - cast!(em.message, EditorMessageContents::LoadFile, a, b); - assert_eq!(em.id, INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT); - send_message( - &mut ws_ide, - &EditorMessage { - id: INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, - message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), - }, - ) - .await; - join_handle.join().unwrap(); + // Define `foo` by rewriting the TOC on disk. After its debounce (2 s, + // checked every 0.5 s, so within `read_message`'s timeout), the watcher + // reports this change, so the consumer re-translates the TOC. + // + // The consumer's initial scan of the project may reach the TOC on either + // side of this write; either way, only one commit defines `foo`, outdating + // the current file once. A scan before the write finds no ids in the TOC. + // A scan after it commits the new TOC, which makes the watcher's report a + // no-op: the TOC's metadata then matches the cache's, so the consumer skips + // it. + fs::write(test_dir.join("toc.md"), "

Foo

\n").unwrap(); // Translating the TOC outdates the current file, which the Server // re-translates, sending the Client the resolved cross-reference. The @@ -1601,7 +1587,7 @@ async fn test_vscode_ide_websocket10() { assert_eq!( read_message(&mut ws_client).await, EditorMessage { - id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + id: INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, message: EditorMessageContents::Update(UpdateMessageContents { file_path: file_path_str.clone(), cursor_position: None, @@ -1637,7 +1623,7 @@ async fn test_vscode_ide_websocket10() { send_message( &mut ws_client, &EditorMessage { - id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + id: INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), }, ) @@ -1645,7 +1631,7 @@ async fn test_vscode_ide_websocket10() { assert_eq!( read_message(&mut ws_ide).await, EditorMessage { - id: INITIAL_MESSAGE_ID + 4.0 * MESSAGE_ID_INCREMENT, + id: INITIAL_MESSAGE_ID + 3.0 * MESSAGE_ID_INCREMENT, message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)), } ); diff --git a/server/src/processing.rs b/server/src/processing.rs index 28361df2..e362046f 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -415,8 +415,8 @@ pub fn find_path_to_toc(file_path: &Path) -> Option { } } -/// Return the cache of the project containing a file, creating it if this -/// project has none. +/// Return the cache of the project containing a file, creating it -- and the +/// threads which keep it current -- if this project has none. pub fn project_cache( // The map of project caches. cache_map: &CacheMap, @@ -440,7 +440,7 @@ pub fn project_cache( cache_map .lock() .unwrap() - .get_or_insert_ref(&root, || Arc::new(Mutex::new(Cache::new(root.clone())))) + .get_or_insert_ref(&root, || Cache::new_project(root.clone())) .clone() } @@ -1497,10 +1497,13 @@ fn hydrate_dom( let dom = html_to_dom(html, None)?; // Read the file's metadata and canonicalize its path before taking the // cache lock, so that no I/O happens while the lock is held. The metadata - // captures the state of the file whose content is being processed; the - // canonicalization satisfies the cache's requirement that all paths are - // canonicalized and absolute. (Canonicalization fails for files which don't - // exist on disk -- such as tests -- in which case the path is used as-is.) + // captures the state of the file whose content is being processed (the + // cache's consumer, which read that content from the file itself, replaces + // this with the metadata it read beforehand; see `scan_file` in + // `cache.rs`); the canonicalization satisfies the cache's requirement that + // all paths are canonicalized and absolute. (Canonicalization fails for + // files which don't exist on disk -- such as tests -- in which case the + // path is used as-is.) let metadata = file.metadata().ok(); let path = canonicalize(file).unwrap_or_else(|_| file.to_path_buf()); // ### Assign requested ids @@ -1539,10 +1542,10 @@ fn hydrate_dom( // diffs them against the file's previous state: added, deleted, and // modified targets and fragments mark the files which depend on them as // outdated, this file is linked to every id it references, and duplicate - // ids are recorded. + // ids are recorded. The commit also sees that each outdated file is + // re-rendered; see `Cache::mark_outdated`. let mut cache_guard = cache.lock().unwrap(); - // TODO: schedule reprocessing for each file in `commit.outdated`. - let _commit = cache_guard.commit_file( + cache_guard.commit_file( &walk_context.path, metadata, mem::take(&mut walk_context.facts), @@ -1718,9 +1721,7 @@ fn store_fragment_contents( // `error` set during the walk. error_html("fragments are not allowed in Markdown documents") }; - // TODO: schedule reprocessing for each outdated file returned here. - let _outdated = - cache_guard.update_fragment_content(&walk_context.path, &fragment.id, content); + cache_guard.update_fragment_content(&walk_context.path, &fragment.id, content); } } @@ -2207,7 +2208,7 @@ struct WalkContext { struct FragmentHydration { /// The fragment's DOM node. node: Rc, - /// The fragment's id. + /// The fragment's id; empty if it has none. id: String, /// The index of the fragment's first code/doc block; see /// `FragmentFact::doc_block_start_index`. @@ -2216,13 +2217,14 @@ struct FragmentHydration { /// number of blocks in the document; see /// `FragmentFact::code_doc_block_end_index`. end: usize, - /// When set, the fragment is in an error state detected during the walk - /// (fragment in a Markdown document, or an unparsable `following` - /// attribute): the message replaces both the hydrated tag content and the - /// cached fragment content. + /// When set, the fragment is in an error state detected during the walk (a + /// missing or invalid id, a fragment in a Markdown document, or an + /// unparsable `following` attribute): the message replaces both the + /// hydrated tag content and the cached fragment content. error: Option, /// False when the fragment wasn't recorded in `FileFacts` (its id is - /// invalid), so no content may be stored for it. + /// missing, invalid, or auto-assigned during this pass), so no content may + /// be stored for it. cached: bool, } @@ -2302,11 +2304,10 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re walk_context.xrefs.push((ref_id, child.clone())); } } else if tag_name == "fragment" { - // A fragment; without an id it's meaningless, so it's ignored. - // Its content can't be determined yet -- doc blocks aren't - // finalized during the walk -- so the caller stores it later - // via `Cache::update_fragment_content`. - if let Some(id) = id { + // A fragment. Its content can't be determined yet -- doc blocks + // aren't finalized during the walk -- so the caller stores it + // later via `Cache::update_fragment_content`. + if let Some(id) = id.filter(|id| !id.is_empty()) { // The `following` attribute selects how many code/doc // blocks after the current doc block the fragment encloses; // the default is 1. @@ -2360,6 +2361,18 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re error, cached, }); + } else { + // A fragment without an id: display an error requesting the + // missing id, per the spec in `cache.rs`. Lacking an id, it + // can't be cached. + walk_context.fragments.push(FragmentHydration { + node: child.clone(), + id: String::new(), + start: walk_context.doc_block_index, + end: walk_context.doc_block_index, + error: Some("a fragment requires an \"id\" attribute".to_string()), + cached: false, + }); } } else if let Some(id) = id && !id.is_empty() diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index ac5c7e59..2f4fe90b 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -28,9 +28,9 @@ Cross references ---------------- A `` is a cross reference to a `Target` or a gather -element. The `id` specifies the destination; the cache then hydrates the -contents based on the location and contents of the target of the provided `id` -to e.g. `Inner HTML from target`; this hydrated form is only present in the Client. @@ -68,7 +68,7 @@ contenteditable="false">See Bazzy things, Zappy things`. A fragment's content by default includes the contents of the current doc block and the contents of the following code/doc block; fragments are not allowed in Markdown -documents (in the case, the fragment contents consist of an error message). +documents (in this case, the fragment contents consist of an error message). Fragments may include the `following` attribute to enclose a specific number of the following code/doc blocks; for example, `` includes the current doc block along with the next 3 @@ -80,10 +80,9 @@ Details: element list of contents will simply include an error message. * Fragments do support indirection: gather element A includes contents from fragment B, which contains a cross reference to target C. Changes to target C - makes B and A outdated. -* If a gather element refers to an `id` that is a `Target` or a `GatherElement`, - not a `Fragment`, the resulting output for this in the list of fragments is an - error message. + make B and A outdated. +* If a gather element refers to an `id` that is a `Target`, not a `Fragment`, + the resulting output for this in the list of fragments is an error message. * If a gather element refers to an `id` that wasn't found or is a duplicate, its contents will be replaced by an error message. * Fragments store an HTML rendering of the code and doc blocks they contain, @@ -92,16 +91,16 @@ Details: exclusion is what keeps a gather element and the fragments it lists from outdating each other forever. The HTML rendering of a fragment reproduces the layout of the source it came from: each doc block includes its indent, each - line of a code block is preceded by that line's number, and the two are - aligned -- a doc block and a line of code indented equally in the source begin - in the same column, with the line numbers in a gutter of their own to the left - of both. + line of a code block or doc block is preceded by that line's number, and the + two are aligned -- a doc block and a line of code indented equally in the + source begin in the same column, with the line numbers in a gutter of their + own to the left of both. * The backlinks a `` hydrates to are derived from the gather elements which list it: for each such element, its containing file, its `id`, and its inner HTML (the link text). A fragment's rendered output therefore depends on the gather elements which reference it -- the reverse of the direction in which references are written; see `Design`. -* A `` is valid; it contains only the current doc block. +* A `` is valid; it contains only the current doc block. * The `following` attribute is clamped if it would exceed the number of code/doc blocks in the document. * If the `following` value cannot be parsed to a whole number, an error message @@ -109,6 +108,8 @@ Details: * A gather element, as a type of `Target`, requires an `id`. A `data-gather` attribute on an element without an `id` produces an error message which requests the missing `id`. +* Likewise, a fragment requires an id, producing an error message when the `id` + is missing. * All ids must be valid [CSS identifiers](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident) per @@ -138,7 +139,8 @@ Search The cache supports searching the (cleaned) inner HTML of all `Target`s and gather elements; search does not include `Fragment` contents. -### Auto-assignment of ids +Auto-assignment of ids +---------------------- If `id="*"` on either a fragment, target, or gather element, the cache replaces this with a random autogenerated `id` placed in the resulting HTML contents, but @@ -147,7 +149,7 @@ this new `id` is not yet recorded in the cache. The file is then marked as the cache. This helps avoid cases where the cache and file contents become unsynchronized: if the `id` is placed in the cache before the write and the write fails, or if the file is never written (it was being scanned, but not -actively edited, so the file contents wasn't written back). +actively edited, so the file contents weren't written back). The autogenerated id must be a valid [CSS identifier](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident). @@ -162,9 +164,9 @@ all consistency comes from that one lock, so no per-item locking (and therefore no lock ordering) is needed. Items refer to each other by key -- files by path, targets and fragments by id -- rather than by `Arc`/`Weak` pointers. This keeps the structure acyclic, `Send`, and (in the future) serializable, and avoids -garbage-collecting stale weak references. Note that the walker, watcher, and -consumer task cache members aren't serializable, but can be re-created after -deserialization. +garbage-collecting stale weak references. Note that the watcher, consumer tx +channel, and connections struct cache members aren't serializable, but can be +re-created after deserialization. Cached state always converges by design. Each layer depends only on lower-numbered layers: @@ -180,9 +182,11 @@ lower-numbered layers: fragment it lists would outdate one another forever. 5. Gather-list hydration depends on layers 1 and 3. -Nothing reads layers 2, 4, or 5, so propagation terminates. +Only layers 1 and 3 are cached, and layer 3 depends only on layer 1 through +layer 2. So a change travels at most two steps (C → B → A), which matches the +indirection example, ensuring that propagation terminates. -Updating the cache is a two-phase process: +Updating the cache is a multi-phase process: 1. Collect: while walking a file's DOM, record all cacheable facts (`FileFacts`) -- targets, cross-references, fragments, and gather elements -- @@ -190,18 +194,22 @@ Updating the cache is a two-phase process: cache and off its critical section. Fact collection ignores content expanded by the cache (the contents of `xref` and `fragment` tags; the content following a gather tag). -2. Commit: `Cache::commit_file` applies the facts in one transaction, diffing - them against the file's previous state to compute the set of files outdated - by these changes. - -Item 2 requires the cache to track dependencies of an item, so that files -containing these dependencies can be Outdated. Dependencies are tracked at file -granularity, on the id rather than on the item defining it: -`IdEntry::dependents` is the set of files whose rendered output depends on this -id, by any means -- an `` or a `data-gather` list naming it. One -uniform set suffices in all three `IdState`s because the typical action taken on -a dependent is marking its containing file outdated (fragments also used their -dependents to generate "See x" links). +2. Commit: `Cache::commit_file` applies the initial facts in one transaction, + diffing them against the file's previous state to compute the set of files + outdated by these changes. +3. Update: `update_fragment_content` store a fragment's rendered content, once + the caller has finalized its doc blocks. +4. Hydrate: `hydrate_gathers` marks each gather element with the `cc-gather` + class and insert its list of gathered fragment contents as its next sibling. + +Item 2 requires the cache to track dependents of an id, so that files containing +these dependencies can be Outdated; these are sent to the `Cache::consumer_tx` +channel for processing. Dependencies are tracked at file granularity, on the id +rather than on the item defining it: `IdEntry::dependents` is the set of files +whose rendered output depends on this id, by any means -- an `` +or a `data-gather` list naming it. One uniform set suffices in all three +`IdState`s because the typical action taken on a dependent is marking this file +outdated (fragments also use their dependents to generate "See x" links). Item 2 also requires the cache to define what constitutes a difference which would trigger outdating dependencies. The state which is checked for differences @@ -225,11 +233,11 @@ lists, and each of those fragments renders a *backlink* to the gather element therefore not merely bookkeeping -- it is observable output in the fragment's own file. -This is why the outgoing references of gather elements cannot be maintained by -the unlink-all/relink-all round trip used for cross-references (see -`commit_file`): that round trip destroys the very information a diff would need. -`commit_file` instead diffs `Target::gather_ids` explicitly, and applies this -rule: +This is why changes to the outgoing references of gather elements cannot be +determined by the unlink-all/relink-all round trip used for cross-references +(see `commit_file`): that round trip destroys the very information a diff would +need. `commit_file` instead diffs `Target::gather_ids` explicitly, and applies +this rule: > When a gather element `G` is committed, outdate the file defining each id in > the symmetric difference of `G`'s old and new `gather_ids`; if `G`'s inner @@ -244,6 +252,8 @@ Propagation terminates here for the reason given under layer 4: rebuilding a fragment's file updates its backlinks, but backlinks are excluded from `Fragment::content`, so nothing further is outdated. +### Missing and duplicate ids + An id referenced before (or without) being defined has an `IdEntry` whose state is `IdState::Missing`; `dependents` holds the files waiting on it. When the id later appears, the state transition marks those files outdated, and they simply @@ -271,10 +281,11 @@ Cache, watcher, and walker -------------------------- The `AppState` structure keeps an LRU map of project path -> cache (note that -the cache includes a watcher and walker). Adding/removing map entries resizes -the LRU size to (connections \* `LRU_ENTRIES_PER_CONNECTION`) + 1. This approach -seeks to balance cache memory consumption with the time required to load the -cache for an uncached project. +the cache includes a watcher and walker). Adding/removing connections resizes +the LRU size to (connections \* `LRU_ENTRIES_PER_CONNECTION`) + 1; however, the +cache for all current connections may not be dropped during an LRU eviction. +This approach seeks to balance cache memory consumption with the time required +to load the cache for an uncached project. The cache fully supports multiple connections to the same project. It does not walk symlinks, since these could lead to two cache entries for the same file. @@ -282,115 +293,110 @@ There's no explicit support for nested projects. While it's possible for one project to be nested inside another, this design does not support this case. The cache initially registers a watcher with the provided project path and a -consumer task which receives results from the walker, along with a message +consumer thread which receives results from the walker, along with a message channel connecting them. -### Watcher +### Watcher/walker -The watcher thread monitors the root directory of the project and all children -for changes using the +The watcher/walker thread monitors the root directory of the project and all +children for changes using the [notify-debouncer-full](https://docs.rs/notify-debouncer-full/latest/notify_debouncer_full/) -crate. It first registers the watcher, to ensure that any changes during the -walk are still reported. Even if the watcher fails, it also starts a filesystem -walker task rooted at the project path, initially sending it the root directory -to walk and providing it the consumer channel and a second walker channel. It -sends file names of files that were created, modified, or deleted and the path -of created or deleted directories to the walker encoded as `WatcherPathState`s -via the walker channel; renames are treated as a delete of the `from` path -followed by a create of the `to` path. If the watcher reports lost events -(`EventKind::Other` with `Flag::Rescan`), the watcher requests a re-walk of the -entire project. A closed channel signals the watcher to exit. If the watcher -can't be started, the user is informed as a warning; any failures to watch files -produce logger warnings, but are not sent to the user. Note that this approach -means extraneous watcher events from ignored directories, such as `.git`, and -additional use of OS resources such as inotify watches. - -### Walker +crate. At startup: + +1. It registers the watcher, to ensure that any changes during the walk are + still reported. + * The watcher sends file names of files that were created, modified, or + deleted and the path of created or deleted directories to the walker + encoded as `WatcherPathState`s via the walker channel; renames are treated + as a delete of the `from` path followed by a create of the `to` path. + * If the watcher encounters lost events (`EventKind::Other` with + `Flag::Rescan`), it requests a re-walk of the entire project. +2. Even if the watcher fails, it then runs a filesystem walker loop rooted at + the project path, initially sending it the root directory to walk and + providing it the consumer channel and a second walker channel. + +A closed channel signals the watcher/walker thread to exit. Note that this +approach means extraneous watcher events from ignored directories, such as +`.git`, and additional use of OS resources such as inotify watches. + +#### Walker The walker is based on the [ignore](https://docs.rs/ignore/latest/ignore/) -crate, allowing users to exclude files from the project. It is implemented as a -thread to support blocking I/O. After the initial traverse of the project, it -keeps an -[IncrementalIgnore](https://docs.rs/ignore/latest/ignore/struct.IncrementalIgnore.html) +crate, allowing users to exclude files from the project. After the initial +traverse of the project, it keeps +an [IncrementalIgnore](https://docs.rs/ignore/latest/ignore/struct.IncrementalIgnore.html) for checking files in the `WatcherPathState`s received from the watcher. * If the path is a deleted file or directory and is not ignored by the walker, - it's sent directly to the consumer task. + it's sent directly to the consumer thread. * If the path refers to any of the walker's ignore files (per the [ignore docs](https://docs.rs/ignore/latest/ignore/struct.WalkBuilder.html), - this is `.gitignore`, `.git/info/exclude`, and `.ignore`), the path containing - this file is re-walked. For simplicity, this occurs even outside a Git - repository. TODO: also have the watcher monitor global ignore files. + this is `.gitignore`, `.git/info/exclude`, and `.ignore`): + * The walker and incremental ignores are reloaded. + * For `.gitignore` and `.ignore`, the path containing this file is re-walked; + for `.git/info/exclude`, the entire tree is re-walked. For simplicity, this + occurs even outside a Git repository, or in subrepos, etc. TODO: also have + the watcher monitor global ignore files. * If the path is a file and is not ignored by the walker, it's sent to the - consumer task. + consumer thread. * If the path is a directory, it's walked; any files found are sent to the - consumer task. + consumer thread. -Before performing a walk, the walker sends a `WalkStarted` to the consumer task; -when completed, a `WalkFinished`. +Before performing a walk, the walker sends a `WalkStarted` to the consumer +thread via the `Cache::consumer_tx` channel; when the walk is completed, it +sends a `WalkFinished`. After processing a path, the walker then loops to wait for the next path. The -task ends when either channel is closed (indicating the cache was dropped). It +thread ends when either channel is closed (indicating the cache was dropped). It ignores any errors produced during the walk, assuming that files it can't walk aren't usable by the IDE. ### Consumer -A consumer task reads paths from the walker channel. It holds a weak reference -to the cache, and exits when this channel is closed or the weak reference can't -be upgraded. It is implemented as an async task to integrate with the processing -pipeline while distributing the CPU time required to translate files. - -* If the file exists, is translatable, and differs from its cached metadata, it - translates the file to the CodeChatForWeb format in order to update the cache - for that file. If the `SaveOnScan` option is enabled, it then translates the - file back to source code and writes the result if there are any changes, - updating the file's metadata in the cache after the write. This costs - additional CPU time and produces watcher events and may rewrite files open in - the IDE, motivating the choice of a user-selected option. - - If processing a file marks any connection's current file or its TOC as - outdated, the connection(s) are sent a message to retranslate the file/IDE - contents (see `Connections`); otherwise, the file is added to the list of - files to process. It does not process the current file for any of its - connections; these are handled by each connection. - +A consumer reads paths from the receiver paired to the `Cache::consumer_tx` +channel. It holds a weak reference to the cache, and exits when this channel is +closed or the weak reference can't be upgraded. It is implemented as a thread. + +* For a `WatcherPathState`: if the file exists and differs from its cached + metadata's modification time and length, perform the following steps. For an + `Outdated`, always perform the following steps: + + * If not translatable, the file is still added to the cache, to prevent + reprocessing if the directory is re-walked. + + * If translatable, it translates the file to the CodeChatForWeb format in + order to update the cache for that file. If the `SaveOnScan` option is + enabled, it then translates the file back to source code and writes the + result if there are any changes, updating the file's metadata in the cache + after the write. This costs additional CPU time and produces watcher events + and may rewrite files open in the IDE, motivating the choice of a + user-selected option. + + If processing a file marks any connection's current file or its TOC as + outdated or deleted, the connection(s) are sent a `CurrentFileOutdated` + and/or `TocOutdated`/`TocChanged` message(s) to retranslate the file/IDE + contents; otherwise, the `Outdated(file)` message is sent to itself via the + consumer TX channel for processing. Note that `TocOutdated`/`TocChanged` + messages are sent to all the cache's connections, since they share the same + project, while `CurrentFileOutdated` is only sent to multiple connections is + it being simultaneously edited. If the TOC is the current file, both + messages are sent. `TocChanged` is sent if the TOC was changed, while + `TocOutdated` is sent when the TOC is only outdated. * If the path doesn't exist, search the cache for it; if found, remove it. Otherwise, assume the path was a directory. Search the cache for all files in - this directory, removing them.
Problem: if a path doesn't exist, perhaps - it's not translatable. This would mean a lot of expensive directory searches - for regular files. Consider some sort of hierarchical filesystem mirror for - this case. + this directory, removing them. * If this is a `WalkStarted`, put all files in the cache which this directory contains in a set. Remove files produced by the walk from this set. After `WalkFinished`, remove any files remaining in the set from the cache, since these no longer exist. -The cache doesn't track changes to images or multimedia files referenced by a -source file, requiring the user to manually refresh the page when these files -change. +The cache doesn't record images or multimedia files referenced by a source file +as dependencies, requiring the user to manually refresh the page when these +files change. ### Connections -Each connection's translation task owns its current file. The cache keeps a -copy for each connection whose current file lies in its project, keyed by -connection id, along with a channel for notifying that connection. A -`ConnectionRegistration` owns each entry: when the current file changes, the -translation task drops its registration, removing the entry from the old -project's cache, then registers with the new file's project; when the connection -closes, dropping the registration removes the entry. Keeping this registry -inside the cache means that checking whether a file is some connection's current -file, and committing that file, happen under the same lock. - -Whenever a commit marks files outdated, the cache sends `CurrentFileOutdated` to -each connection whose current file is among them. The connection re-translates -its current file from the IDE's contents, which may be newer than the file on -disk, then sends any change to the Client as a re-translation. Since the source -is unchanged, the re-translation keeps the file's version, and the IDE isn't -updated. The Client ignores a re-translation while its own edits are unsent or -in flight; those edits are re-translated when they reach the Server. - Every connection in a project also shows the project's TOC in its Client's sidebar, so whenever a commit marks the TOC outdated, the cache sends `TocOutdated` to every connection in the project. The connection re-renders the @@ -408,34 +414,28 @@ trigger another. Instead, edits to the TOC reach the sidebar in other ways: * When a connection's current file is the TOC, the connection refreshes its sidebar after each edit to the TOC, whether made in the IDE or the Client. -* TODO: once the consumer task exists, have it notify every connection in the +* TODO: once the consumer thread exists, have it notify every connection in the project when the TOC changes on disk. Since the TOC's text then differs from the text the sidebar last loaded, this needs a notification other than `TocOutdated`, prompting the connection to fetch the TOC's new text from its IDE (with a `LoadFile`), falling back to disk, before re-rendering it. -A registration holds a strong reference to its cache, so the cache outlives its -LRU map entry while any connection views a file in its project. TODO: keep the -LRU map from evicting a cache with registered connections; until then, the next -translation in an evicted project creates a second cache for it. - ### Shutdown Overall shutdown sequence: 1. The LRU map drops a `Cache` and the upgraded Weak reference by the consumer - task is dropped. -2. The `Cache` drops the `_watcher` and the consumer task. + thread is dropped. +2. The `Cache` drops the `_watcher` and the `consumer_tx` channel. 3. When dropped, the `_watcher` closes its channel to the walker, stops the - watcher, and exits. -4. The walker responds to the closed watcher channel by closing its channel to - the consumer and exits. -5. The consumer task exits when either the walker channel closes or the weak - pointer fails to upgrade. + watcher, drops its `consumer_tx` channel, and exits. +4. The consumer thread exits when either the its consumer rx channel closes or + the weak pointer fails to upgrade. TODO: -* Does the consumer need to be async? Or can it be a plain thread? +* `SaveOnScan` and search functionality is not implemented yet. This is future + work.


diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index e8eceb6c..20158c6c 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -22,33 +22,38 @@ // // ### Standard library use std::{ - collections::{HashMap, HashSet}, - fs::Metadata, + collections::{BTreeMap, HashMap, HashSet}, + fs::{self, File, Metadata}, + io::{self, Read}, mem, + num::NonZeroUsize, + ops::Bound, path::{Path, PathBuf}, - sync::{Arc, Mutex, PoisonError}, + str, + sync::{ + Arc, Mutex, PoisonError, Weak, + mpsc::{self, Receiver, SendError, Sender}, + }, thread, time::Duration, }; // ### Third-party -use ignore::WalkBuilder; -use log::error; +use ignore::{IncrementalIgnore, WalkBuilder}; +use log::warn; use lru::LruCache; use notify_debouncer_full::{ - DebounceEventResult, Debouncer, RecommendedCache, new_debouncer, + DebounceEventResult, Debouncer, RecommendedCache, new_debouncer_opt, notify::{ - self, - EventKind::{Create, Modify, Remove}, - RecommendedWatcher, RecursiveMode, + self, Event, EventKind, RecommendedWatcher, RecursiveMode, + event::{ModifyKind, RenameMode}, }, }; use rand::random; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::sync::mpsc::UnboundedSender; // ### Local -// -// None. +use super::{SourceToCodeChatForWebError, source_to_codechat_for_web}; // Data structures // --------------- @@ -58,27 +63,33 @@ use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; /// so that work on one project doesn't block work on another. pub type CacheMap = Arc>>>>; +/// The number of project caches the `CacheMap` holds per connection; see +/// `cache_map_capacity`. pub const LRU_ENTRIES_PER_CONNECTION: usize = 10; /// This defines the cache used to store all targets in a project. pub struct Cache { - /// Provide rapid access to a file by its absolute path; it must be within - /// the project's root directory. This owns all per-file data. - pub(super) files: HashMap, + /// Provide rapid access to a file by its normalized, absolute path; it must be within + /// the project's root directory. This owns all per-file data. Paths order + /// component by component, so the files beneath a directory immediately + /// follow it in this map: finding them, as a deleted or re-walked directory + /// requires, is a range query rather than a search of every file. + pub(super) files: BTreeMap, /// Provide rapid access to a `Target`/gather element or `Fragment` by its /// id. pub(super) ids: HashMap, - /// All files for which `FileEntry::status != FileStatus::UpToDate`. - pub(super) pending_files: Vec, /// The root directory of this project, or a specific file if this is a /// single-file "project". pub(super) root: PathBuf, - /// Files found by the walker thread (see `walk_project`) or the watcher - /// (see `watch_project`). Dropping the cache drops this receiver, which - /// closes the channel and so stops both. - found_files: UnboundedReceiver, - /// This project's watcher, or `None` for a single-file "project" or if the - /// watcher couldn't start. Dropping it stops the watch. + /// Sends work to this project's consumer thread: the walker sends what it + /// finds through a clone of this sender, `mark_outdated` sends outdated + /// files, and dropping a `ConnectionRegistration` sends the file it showed. + /// `None` if there's no consumer -- a single-file "project", or a test. + consumer_tx: Option>, + /// This project's watcher, which the watcher/walker thread stores here once it + /// registers the watcher; `None` until then, if there's no consumer, or if + /// the watcher couldn't start. Dropping it stops the watch, which begins + /// the shutdown sequence described in the spec. _watcher: Option>, /// The connections whose current file lies in this project, keyed by /// connection id; each entry is owned by a `ConnectionRegistration`. @@ -92,13 +103,12 @@ pub struct Cache { /// `None`. #[derive(Default)] pub(super) struct FileEntry { - /// Metadata used to determine if this data represents the actual state of - /// the file; if the file is newer, then this file's status must be changed - /// to `Unknown`. `None` if the file doesn't exist or the metadata can't be - /// determined. + /// The file's metadata when its content was read, which tells the consumer + /// whether this data still represents the file: the consumer reprocesses a + /// file whose modification time or length differs. `None` if the file + /// doesn't exist or the metadata can't be determined. pub(super) metadata: Option, - /// The status of this file. Note that this overlaps with - /// `Cache::pending_files` and should be kept in sync with it. + /// The status of this file. pub(super) status: FileStatus, /// All targets on this page, keyed by id. pub(super) targets: HashMap, @@ -147,8 +157,8 @@ pub(super) enum IdState { /// The status of a file from the cache's perspective. #[derive(Debug, Default, PartialEq, Eq)] pub(super) enum FileStatus { - /// The file's content is unknown -- either the file hasn't been processed, - /// or it's been modified since it was last processed. + /// The file hasn't been processed: an entry's state until its first + /// commit. #[default] Unknown, /// The file needs to be re-processed to update cross-references or gather @@ -249,9 +259,10 @@ pub(super) struct FragmentFact { /// The result of committing one file's facts to the cache. pub(super) struct CommitOutcome { /// Files (other than the committed file) whose rendered output is - /// invalidated by this commit; their status has already been set to - /// `Outdated`, and each connection showing one of them has been notified. - /// The caller should schedule the rest for reprocessing. + /// invalidated by this commit. Their status has already been set to + /// `Outdated`, and each has been passed on to be re-rendered: to the + /// connections showing it, or else to the consumer; see + /// `Cache::mark_outdated`. pub(super) outdated: HashSet, } @@ -290,17 +301,41 @@ pub(super) struct GatherBacklink<'a> { pub(super) target: &'a Target, } -/// States of paths discovered by the watcher. +// ### Watcher, walker, and consumer +// +// The watcher reports changed paths to the walker, which forwards those its +// ignore files don't exclude -- and the files found by walking each directory +// reported -- to the consumer, which applies them to the cache. See `Cache, +// watcher, and walker` in the spec. +/// A path reported by the watcher. A rename is reported as the deletion of its +/// old path followed by the creation of its new one. +#[derive(Debug, PartialEq, Eq)] pub(super) enum WatcherPathState { + /// A file which was created or modified, or a directory which was created. CreatedOrModified(PathBuf), + /// A file or directory which was deleted. Deleted(PathBuf), } -/// Messages sent by the walker to the consumer task. +/// Messages sent to the consumer. +#[derive(Debug, PartialEq, Eq)] pub(super) enum ConsumerPathState { + /// A path from the walker: a file a walk found, or a path the watcher + /// reported. WatcherPathState(WatcherPathState), + /// The walker is about to walk this directory. Every file the walk finds + /// arrives before the matching `WalkFinished`. WalkStarted(PathBuf), + /// The walk announced by the preceding `WalkStarted` is complete. WalkFinished, + /// A file which a connection stopped showing; see + /// `ConnectionRegistration::drop`. A walk in progress didn't find this + /// file, so unlike the files it does find, this can't keep the walk from + /// forgetting it. + Released(PathBuf), + /// A commit outdated the rendered output of this file, whose content is + /// unchanged; see `Cache::mark_outdated`. + Outdated(PathBuf), } // ### Connections @@ -328,65 +363,83 @@ pub enum CacheNotification { /// project's TOC, which every connection in the project shows in its /// sidebar; the connection should re-render it. TocOutdated, + /// The contents of the TOC changed; the connection should re-load then re-render it. (Since the IDE is editing the current file, changes made to it are superseded by the IDE content, so that a `CurrentFileChanged` message is meaningless. + TocChanged, } /// Lists a connection in `Cache::connections` for as long as this value lives; /// dropping it removes the listing. pub struct ConnectionRegistration { /// The cache the connection is listed in. - cache: Arc>, + cache: Weak>, /// The connection's key in `Cache::connections`. connection_id: String, } // Code // ---- +/// The capacity of the `CacheMap` when the Server has the given number of +/// connections; `translation_task` applies this as connections come and go. +/// Per the spec, this balances the memory the caches consume against the time +/// needed to load the cache of a project which isn't cached. +#[must_use] +pub fn cache_map_capacity( + // The number of connections, $n$. + connections: usize, + // $n c + 1$, where $c$ is `LRU_ENTRIES_PER_CONNECTION`. +) -> NonZeroUsize { + NonZeroUsize::MIN.saturating_add(connections.saturating_mul(LRU_ENTRIES_PER_CONNECTION)) +} + impl Cache { - /// Create an empty cache, then start a watcher and a walker thread which - /// report the project's files; `queue_found_files` collects what they find. + /// Create an empty cache with no consumer to keep it current: the cache of + /// a single-file "project", which consists only of that file, or of a + /// test. A project's cache comes from `Cache::new_project`. #[must_use] pub fn new( // The root directory of this project, as a canonicalized absolute path; // all cached files must live within it. For a single-file "project", // this is that file. root: PathBuf, + // The empty cache. ) -> Self { - let (found_tx, found_rx) = mpsc::unbounded_channel(); - // Start watching before walking, so that a file created during the - // walk is reported by one or the other. A watcher failure isn't fatal: - // the cache still works, but won't learn of files which change after - // the walk. On Linux, this happens once the per-user inotify limits are - // reached, since each watcher uses its own inotify instance. - let _watcher = if root.is_dir() { - let watcher = watch_project(&root, found_tx.clone()) - .inspect_err(|err| error!("Unable to watch {}: {err}", root.display())) - .ok(); - // Start the walker next. - let walk_root = root.clone(); - thread::spawn(move || walk_project(&walk_root, &found_tx)); - watcher - } else { - None - }; Cache { - files: HashMap::new(), + files: BTreeMap::new(), ids: HashMap::new(), - pending_files: vec![], root, - found_files: found_rx, - _watcher, + consumer_tx: None, + _watcher: None, connections: HashMap::new(), } } - /// Mark every file the walker has found so far, which the cache doesn't - /// already know of, as `Unknown`, queueing it for processing. - pub(super) fn queue_found_files(&mut self) { - while let Ok(path) = self.found_files.try_recv() { - if !self.files.contains_key(&path) { - self.mark_unknown(&path); - } - } + /// Create the cache of a project, then start the threads which keep it + /// current: a watcher thread, which registers a watcher to report changes + /// to the project's files, then becomes the walker, which filters those + /// reports through the project's ignore files; and a consumer, which + /// applies the walker's results to the cache. The walker's first task is a + /// walk of the entire project, so that the consumer brings every file in it + /// into the cache. See `Cache, watcher, and walker` in the spec. + #[must_use] + pub fn new_project( + // The project's root directory, as a canonicalized absolute path; all + // cached files must live within it. + root: PathBuf, + // The cache, shared with the threads which keep it current. + ) -> Arc> { + let (consumer_tx, consumer_rx) = mpsc::channel(); + let walker_consumer_tx = consumer_tx.clone(); + let cache = Arc::new(Mutex::new(Cache { + consumer_tx: Some(consumer_tx), + ..Cache::new(root.clone()) + })); + // Give these threads only weak references, so that they don't keep the + // cache alive; see `Shutdown` in the spec. + let weak_cache = Arc::downgrade(&cache); + thread::spawn(move || consume(&weak_cache, &consumer_rx)); + let weak_cache = Arc::downgrade(&cache); + thread::spawn(move || watch_then_walk(&weak_cache, root, walker_consumer_tx)); + cache } /// Apply the facts collected from one file's DOM walk to the cache in a @@ -621,7 +674,7 @@ impl Cache { entry.fragments = new_fragments; entry.gathers = new_gathers; entry.xrefs = facts.xrefs.into_iter().map(|id| Xref { id }).collect(); - self.set_status(path, FileStatus::UpToDate); + entry.status = FileStatus::UpToDate; // ### Link the new version's outgoing references // @@ -658,47 +711,9 @@ impl Cache { ) -> CommitOutcome { let outcome = self.commit_file(path, None, FileFacts::default()); self.files.remove(path); - self.pending_files.retain(|pending| pending != path); outcome } - /// Record that a file's cached content no longer reflects the file - /// itself -- it's been written or modified since it was last processed -- - /// which queues it in `pending_files` for reprocessing. This is how an id - /// auto-assigned during hydration reaches the cache: the assignment is only - /// in the file until the file is saved, re-read, and committed. - pub(super) fn mark_unknown( - &mut self, - // The file whose content is now unknown. - path: &Path, - ) { - self.files.entry(path.to_path_buf()).or_default(); - self.set_status(path, FileStatus::Unknown); - } - - /// Set a file's status, keeping `pending_files` -- which holds exactly the - /// files whose status is `Unknown` -- in sync with it. - fn set_status( - &mut self, - // The file whose status changed; nothing happens if the cache has no - // entry for it. - path: &Path, - // Its new status. - status: FileStatus, - ) { - let Some(entry) = self.files.get_mut(path) else { - return; - }; - let unknown = status == FileStatus::Unknown; - entry.status = status; - let queued = self.pending_files.iter().any(|pending| pending == path); - if unknown && !queued { - self.pending_files.push(path.to_path_buf()); - } else if !unknown && queued { - self.pending_files.retain(|pending| pending != path); - } - } - /// Generate an id for an element which requests one (`id="*"`). The result /// is deliberately *not* recorded in the cache: it enters the cache only /// once the file holding it is written and re-read, so that a write which @@ -777,21 +792,40 @@ impl Cache { outdated } - /// Mark files outdated, then notify each connection showing one of them: - /// as its current file, or as the TOC in its sidebar, which every - /// connection in the project shows. The connection re-renders that file - /// itself, since the text it shows may be newer than the file on disk. + /// Mark files outdated, then see that each is re-rendered. A connection + /// re-renders a file it shows -- its current file, or the TOC in its + /// sidebar, which every connection in the project shows -- since the text + /// it shows may be newer than the file on disk; the consumer reprocesses + /// the rest from disk. fn mark_outdated( &mut self, // The files to mark. outdated: &HashSet, ) { - for outdated_path in outdated { - self.set_status(outdated_path, FileStatus::Outdated); + for path in outdated { + if let Some(entry) = self.files.get_mut(path) { + entry.status = FileStatus::Outdated; + } } - // The project's root is the directory containing its TOC; see - // `find_path_to_toc`. - let is_toc_outdated = outdated.contains(&self.root.join("toc.md")); + self.notify_connections(outdated); + if let Some(consumer_tx) = &self.consumer_tx { + for path in outdated.iter().filter(|path| !self.is_shown(path)) { + // A send fails only once the consumer has exited, when this + // cache is being dropped and so needs no more updates. + let _ = consumer_tx.send(ConsumerPathState::Outdated(path.clone())); + } + } + } + + /// Notify each connection showing one of these files -- as its current + /// file, or as the TOC in its sidebar -- so that the connection re-renders + /// the file. + fn notify_connections( + &self, + // The files whose rendered output is outdated. + outdated: &HashSet, + ) { + let is_toc_outdated = outdated.contains(&self.toc_path()); for connection in self.connections.values() { // A send fails only once the connection's translation task has // exited, leaving nothing to notify. @@ -806,6 +840,69 @@ impl Cache { } } + /// The project's TOC: the project's root is the directory containing it; + /// see `find_path_to_toc`. + fn toc_path( + &self, + // The TOC's path. + ) -> PathBuf { + self.root.join("toc.md") + } + + /// Report whether a connection shows this file, which makes the connection + /// responsible for re-rendering it: it's a connection's current file, or + /// it's the TOC, which every connection's sidebar shows. + fn is_shown( + &self, + // The file to check. + path: &Path, + // True if a connection shows the file. + ) -> bool { + self.is_current_file(path) || (!self.connections.is_empty() && path == self.toc_path()) + } + + /// Report whether this file is some connection's current file. + fn is_current_file( + &self, + // The file to check. + path: &Path, + // True if it's some connection's current file. + ) -> bool { + self.connections + .values() + .any(|connection| connection.current_file == path) + } + + /// Iterate over the cached files beneath a directory, which immediately + /// follow the directory itself in `files`. + fn files_beneath<'a>( + &'a self, + // The directory. + dir: &'a Path, + // The cached files beneath it, at any depth. + ) -> impl Iterator { + self.files + .range::((Bound::Excluded(dir), Bound::Unbounded)) + .map(|(path, _)| path) + .take_while(move |path| path.starts_with(dir)) + } + + /// Forget a file which no longer exists, or which the walker now ignores + /// -- unless a connection shows it. The connection's IDE may still hold the + /// file's text, so instead have the connection re-translate that text, + /// which keeps the file in the cache. + fn forget_file( + &mut self, + // The file to forget. + path: &Path, + ) { + if self.is_shown(path) { + self.notify_connections(&HashSet::from([path.to_path_buf()])); + } else if self.files.contains_key(path) { + self.remove_file(path); + } + } + /// Look up an id, returning the target or fragment it names along with the /// defining file, `Missing` if no file defines it, or `Multiple` if more /// than one definition exists. @@ -953,11 +1050,21 @@ impl Drop for ConnectionRegistration { fn drop(&mut self) { // Tolerate a poisoned lock: this may run while unwinding from another // panic, where a second panic aborts the process. - self.cache - .lock() - .unwrap_or_else(PoisonError::into_inner) - .connections - .remove(&self.connection_id); + let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + let Some(connection) = cache.connections.remove(&self.connection_id) else { + return; + }; + // The consumer leaves a connection's current file to that connection, + // so it may have skipped a change to this file on disk, or its + // deletion. Once no connection shows the file, have the consumer check + // the file against the disk. If the file on disk is unchanged, what + // the connection committed from its IDE's text stays in the cache, + // since the IDE may still hold that text. + if !cache.is_current_file(&connection.current_file) + && let Some(consumer_tx) = &cache.consumer_tx + { + let _ = consumer_tx.send(ConsumerPathState::Released(connection.current_file)); + } } } @@ -1014,86 +1121,606 @@ fn gathered_ids<'a>( .flat_map(|target| target.gather_ids.iter()) } -/// Watch `root` recursively, sending the path of every file created, modified, -/// or removed within it to the cache. This runs on the debouncer's thread. +// ### Watcher +/// The watcher thread: register a watcher for the project and give it to the +/// cache, then become the walker, starting with a walk of the entire project. +/// Registering the watcher can take a while -- on some platforms, it visits +/// every file in the project -- so this runs on its own thread, spawned by +/// `Cache::new_project`, rather than delaying the translation which created the +/// cache. +fn watch_then_walk( + // The cache which owns the watcher. + cache: &Weak>, + // The project's root directory. + root: PathBuf, + // Where the walker sends what it finds. + consumer_tx: Sender, +) { + let (walker_tx, walker_rx) = mpsc::channel(); + // Start watching before walking, so that a file created during the walk is + // reported by one or the other. A watcher failure isn't fatal: the cache + // still works, but won't learn of files which change after the walk. On + // Linux, this happens once the per-user inotify limits are reached, since + // each watcher uses its own inotify instance. + // + // TODO: inform the user of this failure, as the spec requires, once the + // Server can show the user a warning. + let watcher = watch_project(&root, walker_tx.clone()) + .inspect_err(|err| warn!("Unable to watch {}: {err}", root.display())) + .ok(); + // The cache owns the watcher, so that dropping the cache stops the watch. A + // cache which is already gone leaves nothing to walk for; returning drops + // the watcher, stopping the watch. + let Some(cache) = cache.upgrade() else { + return; + }; + // The field's leading underscore marks it as held only for what dropping + // it does; this is the one place which sets it. + #[allow(clippy::used_underscore_binding)] + { + cache.lock().unwrap()._watcher = watcher; + } + drop(cache); + // Give the walker its first task, then drop this sender. Only the watcher + // holds another, so if it failed, the walker exits after this walk. The + // send can't fail, since `walker_rx` is alive. + let _ = walker_tx.send(WatcherPathState::CreatedOrModified(root.clone())); + drop(walker_tx); + Walker::new(root, consumer_tx).run(&walker_rx); +} + +/// Watch `root` recursively, sending the walker each path created, modified, +/// or deleted within it. The event handler runs on the debouncer's thread. fn watch_project( // The project's root directory. root: &Path, - // Where to send each file reported; the cache owns the receiving end. - found: UnboundedSender, + // Where to send each path reported; the walker owns the receiving end. + walker_tx: Sender, // The running watcher; dropping it stops the watch. ) -> notify::Result> { - // The watcher only needs to learn of files added to or removed from the - // project. notify 8 has no `Config::with_event_kinds`, so the event handler - // must discard events which don't match this mask itself. + let handler_root = root.to_path_buf(); + // The watcher only needs to learn of files added to, removed from, or + // modified in the project. notify 8 has no `Config::with_event_kinds`, so + // `watcher_path_states` must discard the events which don't matter. // - // TODO: pass this mask to `Config::with_event_kinds` once + // TODO: pass the kinds which matter to `Config::with_event_kinds` once // notify-debouncer-full depends on notify 9. - let mut debouncer = new_debouncer( + let mut debouncer = new_debouncer_opt::<_, RecommendedWatcher, RecommendedCache>( Duration::from_secs(2), None, move |result: DebounceEventResult| match result { - Ok(debounced_event_vec) => { - for debounced_event in debounced_event_vec { - match debounced_event.event.kind { - Create(_) | Modify(_) | Remove(_) => { - for path in debounced_event.event.paths { - if let Err(err) = found.send(path) { - // The only possible source of [error](https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Sender.html#errors) per the docs is a closed channel. This occurs when the cache is dropped, meaning the watcher should stop. - return; - } - } - } - _ => (), + Ok(debounced_events) => { + for path_state in debounced_events + .iter() + .flat_map(|debounced_event| watcher_path_states(debounced_event, &handler_root)) + { + // A send fails only once the walker has exited, when the + // cache is being dropped -- along with this watcher. + if walker_tx.send(path_state).is_err() { + return; } } } - // TODO: should we sent errors to the cache for better UI? Or ignore them, given they're files we couldn't watch, and therefore couldn't access anyway? - Err(err_vec) => { - for err in err_vec { - error!("Error: {err}"); + // A path which can't be watched isn't usable by the IDE either, so + // per the spec, report this only to the log. + Err(errors) => { + for err in errors { + warn!("Unable to watch {}: {err}", handler_root.display()); } } }, + RecommendedCache::new(), + // Per the spec, symlinks aren't followed, since a file reached through + // one would be cached twice. The walker skips a symlink the watcher + // reports; this keeps the watcher from reporting the files beneath a + // symlinked directory, which on some platforms it would otherwise + // watch. + notify::Config::default().with_follow_symlinks(false), )?; debouncer.watch(root, RecursiveMode::Recursive)?; Ok(debouncer) } -/// Send every file under `path` -- or `path` itself, if it's a file -- to the -/// cache, stopping early once the cache is dropped. This runs on its own -/// thread, spawned by `Cache::new`. -/// -/// The walk applies the [ignore](https://docs.rs/ignore) crate's default -/// filters, which are ripgrep's: it skips hidden files and directories (such as -/// `.git`), anything excluded by an `.ignore` file, and -- only inside a git -/// repository -- anything excluded by `.gitignore` or git's other exclude -/// rules. -fn walk_project( - // The file or directory to walk. +/// Translate one watcher event into the paths it reports to the walker: per +/// the spec, the files created, modified, or deleted, and the directories +/// created or deleted. +fn watcher_path_states( + // The event to translate. + event: &Event, + // The project's root directory, which is re-walked if the watcher lost + // events. + root: &Path, + // The paths to report, in order. +) -> Vec { + use WatcherPathState::{CreatedOrModified, Deleted}; + + if event.need_rescan() { + return vec![CreatedOrModified(root.to_path_buf())]; + } + let mut paths = event.paths.iter().cloned(); + match event.kind { + // The debouncer stitches the two halves of a rename into one event, + // listing the path renamed from, then the path renamed to, when it can + // match them; otherwise, it passes each half through on its own, as a + // deletion or a creation. + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => paths + .next() + .map(Deleted) + .into_iter() + .chain(paths.map(CreatedOrModified)) + .collect(), + EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(RenameMode::To)) => { + paths.map(CreatedOrModified).collect() + } + EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { + paths.map(Deleted).collect() + } + // The platform couldn't tell which half of a rename this is, so ask + // the filesystem. + EventKind::Modify(ModifyKind::Name(_)) => paths + .map(|path| { + if path.exists() { + CreatedOrModified(path) + } else { + Deleted(path) + } + }) + .collect(), + // A directory is modified whenever its contents change, which are + // reported in their own right, so skip directories here. + EventKind::Modify(_) | EventKind::Any => paths + .filter(|path| !path.is_dir()) + .map(CreatedOrModified) + .collect(), + EventKind::Access(_) | EventKind::Other => vec![], + } +} + +// ### Walker +/// The walker, which filters the paths the watcher reports through the +/// project's ignore files and walks each directory reported, sending the +/// results to the consumer. See `Walker` in the spec. +struct Walker { + /// The project's root directory. + root: PathBuf, + /// Decides whether a path the watcher reports is ignored, without walking + /// the project. It keeps the ignore files it reads, so it must be rebuilt + /// when one of them changes. + ignore: IncrementalIgnore, + /// Where to send the files found. + consumer_tx: Sender, +} + +impl Walker { + /// Create the walker for a project. + fn new( + // The project's root directory. + root: PathBuf, + // Where to send the files found. + consumer_tx: Sender, + // The walker, which reads the project's ignore files only as it needs + // them. + ) -> Self { + Walker { + ignore: build_ignore(&root), + root, + consumer_tx, + } + } + + /// Process each path the watcher sends, until the walker's channel closes + /// -- when the cache is dropped, which drops the watcher, or after the + /// first walk if the watcher couldn't start -- or the consumer exits, + /// closing its own channel. This runs on the watcher thread; see + /// `watch_then_walk`. + fn run( + mut self, + // The paths the watcher sends. + walker_rx: &Receiver, + ) { + while let Ok(path_state) = walker_rx.recv() { + if self.process(path_state).is_err() { + return; + } + } + } + + /// Process one path the watcher sent, sending the results to the consumer. + fn process( + &mut self, + // The path to process. + path_state: WatcherPathState, + // An error once the consumer has exited. + ) -> Result<(), SendError> { + let (WatcherPathState::CreatedOrModified(path) | WatcherPathState::Deleted(path)) = + &path_state; + // An ignore file is hidden, which makes it ignored itself, so look for + // one first. + if let Some(dir) = self.rewalk_dir(path) { + return self.walk(&dir); + } + if let WatcherPathState::CreatedOrModified(path) = &path_state + && let Ok(metadata) = fs::symlink_metadata(path) + { + // Per the spec, symlinks aren't walked, since a file reached + // through one would be cached twice. + if metadata.is_dir() { + return self.walk(path); + } + if metadata.is_symlink() { + return Ok(()); + } + } + // Nothing says whether a deleted path was a file or a directory. + // Checking it as a file errs toward sending it to the consumer, which + // costs only a search of the cache for files which aren't there. + if self.is_ignored(path, false) { + Ok(()) + } else { + self.consumer_tx + .send(ConsumerPathState::WatcherPathState(path_state)) + } + } + + /// If `path` is one of the ignore files which the walk obeys, return the + /// directory to re-walk, which reloads the ignore files: the directory an + /// `.ignore` or `.gitignore` governs, or the entire project for + /// `.git/info/exclude`. A file with an ignore file's name prompts this even + /// where Git wouldn't read it -- outside a repository, for example -- which + /// costs only an unneeded walk. + fn rewalk_dir( + &self, + // The path to check. + path: &Path, + // The directory to re-walk, or `None` if this isn't an ignore file. + ) -> Option { + let dir = path.parent()?; + match path.file_name()?.to_str()? { + ".ignore" | ".gitignore" => Some(dir.to_path_buf()), + "exclude" if dir.ends_with(Path::new(".git").join("info")) => Some(self.root.clone()), + _ => None, + } + } + + /// Report whether the walk skips this path: it's hidden or excluded by an + /// ignore file, or it lies within a directory which is. + fn is_ignored( + &mut self, + // The path to check. + path: &Path, + // True if the path is a directory, which some ignore rules match + // specifically. + is_dir: bool, + // True if the walk skips the path. + ) -> bool { + // The matcher takes paths relative to the project's root; anything + // outside the project is ignored. + path.strip_prefix(&self.root) + .ok() + .is_none_or(|relative| self.ignore.matched(relative, is_dir).is_ignore()) + } + + /// Walk a directory, sending the consumer each file found, preceded by a + /// `WalkStarted` and followed by a `WalkFinished`. + fn walk( + &mut self, + // The directory to walk. + dir: &Path, + // An error once the consumer has exited. + ) -> Result<(), SendError> { + // A walk never filters its own starting point, so skip an ignored + // directory here. This includes a directory whose ignore file changed: + // the walk never enters that directory, so the change can't matter, + // which spares a reload for each such file a package manager creates, + // say. + if self.is_ignored(dir, true) { + return Ok(()); + } + // The walk reads the ignore files afresh, so rebuild the matcher to + // agree with it. The ignore files may have changed without the watcher + // reporting it: the debouncer drops the events within a deleted + // directory, for example, including the deletion of an ignore file + // there. + self.ignore = build_ignore(&self.root); + self.consumer_tx + .send(ConsumerPathState::WalkStarted(dir.to_path_buf()))?; + // The walk reports an error (an unreadable directory, say) as an entry + // of its own, then continues. Skip these, since a file the walk can't + // read isn't usable by the IDE either. Symlinks aren't followed, so a + // symlink loop can't make the walk run forever. + for entry in WalkBuilder::new(dir).build().flatten() { + if entry + .file_type() + .is_some_and(|file_type| file_type.is_file()) + { + self.consumer_tx.send(ConsumerPathState::WatcherPathState( + WatcherPathState::CreatedOrModified(entry.into_path()), + ))?; + } + } + self.consumer_tx.send(ConsumerPathState::WalkFinished) + } +} + +/// Build the matcher which decides whether a path the watcher reports is +/// ignored. It applies the same filters as every walk: the +/// [ignore](https://docs.rs/ignore) crate's defaults, which are ripgrep's. These +/// skip hidden files and directories (such as `.git`), anything excluded by an +/// `.ignore` file, and -- only inside a Git repository -- anything excluded by +/// `.gitignore` or Git's other exclude rules. +fn build_ignore( + // The project's root directory, to which the matcher's paths are relative. + root: &Path, + // The matcher, which reads the ignore files only as it needs them. +) -> IncrementalIgnore { + WalkBuilder::new(root) + .build_matchers() + .pop() + .expect("A builder given one path builds one matcher.") +} + +// ### Consumer +/// Apply each message sent to the consumer, until the channel closes or the +/// cache is dropped. This runs on its own thread, spawned by +/// `Cache::new_project`. +fn consume( + // The cache to update. This is upgraded only while applying a message, so + // that the consumer doesn't keep a dropped cache alive while it waits for + // the next one. + cache: &Weak>, + // The messages to apply. + consumer_rx: &Receiver, +) { + let mut consumer = Consumer::default(); + while let Ok(message) = consumer_rx.recv() { + let Some(cache) = cache.upgrade() else { + return; + }; + consumer.apply(&cache, message); + } +} + +/// The consumer's state between messages. +#[derive(Default)] +struct Consumer { + /// While a walk is in progress, the cached files beneath the directory + /// being walked which the walk hasn't found; `None` otherwise. + unwalked: Option>, +} + +impl Consumer { + /// Apply one message to the cache. + fn apply( + &mut self, + // The cache to update. + cache: &Arc>, + // The message to apply. + message: ConsumerPathState, + ) { + match message { + // The walker walks one directory at a time, so this replaces only + // the state of a walk which never finished; lacking its + // `WalkFinished`, the files it didn't find can't be presumed gone. + ConsumerPathState::WalkStarted(dir) => { + self.unwalked = Some(cache.lock().unwrap().files_beneath(&dir).cloned().collect()); + } + // A cached file which the walk didn't find no longer exists, or is + // now ignored. Release the lock between files, so that forgetting + // many doesn't hold up translations. + ConsumerPathState::WalkFinished => { + if let Some(unwalked) = self.unwalked.take() { + for path in unwalked { + cache.lock().unwrap().forget_file(&path); + } + } + } + ConsumerPathState::WatcherPathState(WatcherPathState::CreatedOrModified(path)) => { + if let Some(unwalked) = &mut self.unwalked { + unwalked.remove(&path); + } + scan_file(cache, &path, false); + } + // Per the spec, only a path which no longer exists is forgotten. + // One which exists again by the time this is applied -- recreated + // by a version control checkout, say, or by an editor which saves + // by renaming a new file over the old one -- is scanned instead. + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(path)) => { + match fs::symlink_metadata(&path) { + Ok(metadata) if !metadata.is_symlink() => scan_file(cache, &path, false), + _ => forget_path(cache, &path), + } + } + ConsumerPathState::Released(path) => scan_file(cache, &path, false), + ConsumerPathState::Outdated(path) => scan_file(cache, &path, true), + } + } +} + +/// Forget a path which no longer exists. Nothing says whether it was a file or +/// a directory, so if the cache has no file at this path, assume it was a +/// directory and forget every cached file beneath it. The lock is released +/// between files, so that forgetting a large directory doesn't hold up +/// translations. +fn forget_path( + // The cache to update. + cache: &Mutex, + // The path which no longer exists. + path: &Path, +) { + let paths: Vec = { + let cache = cache.lock().unwrap(); + if cache.files.contains_key(path) { + vec![path.to_path_buf()] + } else { + cache.files_beneath(path).cloned().collect() + } + }; + for path in paths { + cache.lock().unwrap().forget_file(&path); + } +} + +/// The outcome of the consumer's translation of a file. +enum ScanOutcome { + /// The translation committed the file's facts to the cache. + Translated, + /// The file isn't text, or no lexer recognizes it, so nothing was + /// committed. + Untranslatable, + /// The translation failed, perhaps after committing the file's facts. + Failed, +} + +/// Bring the cache's record of a file up to date with the file on disk by +/// translating the file to the CodeChatForWeb format, which commits its facts +/// to the cache. Per the spec, a connection's current file is skipped: the +/// connection keeps it up to date, from text which may be newer than the file +/// on disk. +fn scan_file( + // The cache to update. + cache: &Arc>, + // The file to scan. path: &Path, - // Where to send each file found; the cache owns the receiving end. - found: &UnboundedSender, + // True if a commit outdated the file, whose content is unchanged; it's + // scanned unless it's been reprocessed since. False if the file itself may + // have changed; it's scanned unless its cached metadata's modification + // time and length match the file's. + is_outdated: bool, ) { - // The walk reports an error (an unreadable directory, say) as an entry of - // its own, then continues; skip these. - for entry in WalkBuilder::new(path).build().flatten() { - // Check before every entry, not just when sending a file, so a walk - // through a large tree containing few files still stops promptly. - if found.is_closed() { + // Perform I/O before locking the cache, so that the lock isn't held during + // it. + let metadata = match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => metadata, + // The file no longer exists -- or a directory has replaced it, which + // the walker reports separately. + _ => { + cache.lock().unwrap().forget_file(path); return; } - // Symlinks aren't followed, so a symlink loop can't make this walk - // forever. - if entry - .file_type() - .is_some_and(|file_type| file_type.is_file()) - && found.send(entry.into_path()).is_err() - { - // Just as the watcher, a send failure means the cache is dropped, so exit. + }; + { + let cache = cache.lock().unwrap(); + if cache.is_current_file(path) { + return; + } + let entry = cache.files.get(path); + let is_up_to_date = if is_outdated { + entry.is_none_or(|entry| entry.status != FileStatus::Outdated) + } else { + entry.is_some_and(|entry| { + entry.status == FileStatus::UpToDate + && entry + .metadata + .as_ref() + .is_some_and(|cached| is_unmodified(cached, &metadata)) + }) + }; + if is_up_to_date { return; } } + // A file which can't be read -- another program may have it locked, for + // example -- leaves the cache as it is; the watcher reports the file again + // once it changes. + let Ok(contents) = read_text(path) else { + return; + }; + // The lexer requires Unix-style line endings. + // + // TODO: once the Server has user-selected options, implement the spec's + // `SaveOnScan` option here: translate the result back to source, then + // write any changes to the file and update its cached metadata. + let outcome = match contents.map(|contents| { + source_to_codechat_for_web( + &contents.replace("\r\n", "\n"), + path, + 0.0, + false, + Some(cache.clone()), + ) + }) { + Some(Ok(_)) => ScanOutcome::Translated, + None + | Some(Err( + SourceToCodeChatForWebError::NoLexer | SourceToCodeChatForWebError::UnknownLexer(_), + )) => ScanOutcome::Untranslatable, + Some(Err(err)) => { + warn!("Unable to scan {}: {err}", path.display()); + ScanOutcome::Failed + } + }; + let mut cache = cache.lock().unwrap(); + if cache.is_current_file(path) { + // A connection made this file its current file during the translation, + // which may then have committed the file's content on disk over the + // content the connection shows, which may be newer. Have the + // connection re-translate its content to restore it. + cache.notify_connections(&HashSet::from([path.to_path_buf()])); + return; + } + match outcome { + // The translation's commit recorded the file's metadata as of the + // commit, which follows the reading of its text. A write in between + // would leave the cache describing the old text with the new metadata, + // so the consumer would skip the watcher's report of that write. + // Record the metadata read before the text instead, which any such + // write changes. + ScanOutcome::Translated => { + if let Some(entry) = cache.files.get_mut(path) { + entry.metadata = Some(metadata); + } + } + // A file which can't be translated defines nothing. Committing it as + // such withdraws whatever it defined before it stopped being + // translatable, and records its metadata, so that later walks skip it + // until it changes rather than reading it again. + ScanOutcome::Untranslatable => { + cache.commit_file(path, Some(metadata), FileFacts::default()); + } + // Leave the cache as it is: without metadata matching the file, the + // next walk tries again. + ScanOutcome::Failed => {} + } +} + +/// Read a file as text. The walker sends the consumer every file in the +/// project, including any large media files, so a file is read in full only +/// once its first block proves to be text. +fn read_text( + // The file to read. + path: &Path, + // Its contents; `None` if it isn't UTF-8, which most likely makes it a + // binary file. +) -> io::Result> { + // The size of the block checked before reading the rest of the file. + const FIRST_BLOCK_LEN: u64 = 8192; + let mut file = File::open(path)?; + let mut bytes = Vec::new(); + file.by_ref() + .take(FIRST_BLOCK_LEN) + .read_to_end(&mut bytes)?; + // A character may straddle the end of the block, leaving an incomplete + // sequence there, for which `error_len` returns `None`. + if let Err(err) = str::from_utf8(&bytes) + && err.error_len().is_some() + { + return Ok(None); + } + file.read_to_end(&mut bytes)?; + Ok(String::from_utf8(bytes).ok()) +} + +/// Report whether two snapshots of a file's metadata agree on its modification +/// time and length, which the consumer takes to mean that the file's contents +/// are unchanged. +fn is_unmodified( + // The metadata cached when the file was last processed. + cached: &Metadata, + // The file's metadata now. + current: &Metadata, + // True if the modification times and lengths match. +) -> bool { + cached.len() == current.len() && cached.modified().ok() == current.modified().ok() } impl Default for Cache { @@ -1108,17 +1735,29 @@ impl Default for Cache { mod tests { use std::{ collections::{HashMap, HashSet}, + fs, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + mpsc::{self as std_mpsc, Receiver}, + }, + thread::sleep, + time::{Duration, Instant}, }; + use notify_debouncer_full::notify::{ + Event, EventKind, + event::{AccessKind, CreateKind, DataChange, Flag, ModifyKind, RemoveKind, RenameMode}, + }; use test_utils::prep_test_dir; use tokio::sync::mpsc::{self, error::TryRecvError}; use super::{ - Cache, CacheNotification, ConnectionRegistration, FileFacts, FileStatus, FragmentFact, - IdResolution, IdState, TargetFact, walk_project, + Cache, CacheNotification, ConnectionRegistration, Consumer, ConsumerPathState, FileFacts, + FileStatus, FragmentFact, IdResolution, IdState, LRU_ENTRIES_PER_CONNECTION, TargetFact, + Walker, WatcherPathState, cache_map_capacity, watcher_path_states, }; + use crate::webserver::canonicalize; // ### Test helpers // @@ -1198,6 +1837,36 @@ mod tests { paths.iter().map(|path| (*path).clone()).collect() } + // Collect every message sent so far on a channel. + fn drain(rx: &Receiver) -> Vec { + rx.try_iter().collect() + } + + // Build the message the walker sends the consumer for a file. + fn found(path: &Path) -> ConsumerPathState { + ConsumerPathState::WatcherPathState(WatcherPathState::CreatedOrModified(path.to_path_buf())) + } + + // Register a connection whose current file is `current_file`, returning its + // registration and the receiver of its notifications. + fn register( + cache: &Arc>, + connection_id: &str, + current_file: &Path, + ) -> ( + ConnectionRegistration, + mpsc::UnboundedReceiver, + ) { + let (notify_tx, notify_rx) = mpsc::unbounded_channel(); + let registration = ConnectionRegistration::new( + cache.clone(), + connection_id.to_string(), + current_file.to_path_buf(), + notify_tx, + ); + (registration, notify_rx) + } + // ### Cross-reference tests // // Verify that a cross-reference to an existing target records the @@ -1336,28 +2005,6 @@ mod tests { assert!(cache.files.is_empty()); } - // Verify that `pending_files` holds exactly those files whose status is - // `Unknown`. - #[test] - fn test_pending_files() { - let mut cache = Cache::default(); - let a = PathBuf::from("a.md"); - cache.commit_file(&a, None, facts_target("foo", "Foo!")); - assert!(cache.pending_files.is_empty()); - - // A file written or modified since it was processed is queued for - // reprocessing, and queued only once. - cache.mark_unknown(&a); - cache.mark_unknown(&a); - assert_eq!(*status(&cache, &a), FileStatus::Unknown); - assert_eq!(cache.pending_files, vec![a.clone()]); - - // Reprocessing it dequeues it. - cache.commit_file(&a, None, facts_target("foo", "Foo!")); - assert_eq!(*status(&cache, &a), FileStatus::UpToDate); - assert!(cache.pending_files.is_empty()); - } - // Verify that an auto-assigned id is 10 characters drawn from // `[A-Za-z0-9_-]` and collides neither with a cached id nor with another id // assigned in the same pass. @@ -1381,6 +2028,26 @@ mod tests { } } + // Verify that the files beneath a directory are found, at any depth, and + // that no file merely sharing a prefix of the directory's name is. + #[test] + fn test_files_beneath() { + let mut cache = Cache::default(); + let beneath = [PathBuf::from("a/b/c.md"), PathBuf::from("a/b/d/e.md")]; + let not_beneath = [ + PathBuf::from("a/b"), + PathBuf::from("a/b.md"), + PathBuf::from("a/bc/f.md"), + PathBuf::from("g.md"), + ]; + for path in beneath.iter().chain(not_beneath.iter()) { + cache.commit_file(path, None, FileFacts::default()); + } + let found: Vec<&PathBuf> = cache.files_beneath(Path::new("a/b")).collect(); + assert_eq!(found, beneath.iter().collect::>()); + assert_eq!(cache.files_beneath(Path::new("h")).count(), 0); + } + // ### Duplicate id tests // // Verify that duplicate ids are recorded as such, in one file or across @@ -1724,9 +2391,7 @@ mod tests { .lock() .unwrap() .commit_file(&b, None, facts_xref("foo")); - let (notify_tx, mut notify_rx) = mpsc::unbounded_channel(); - let registration = - ConnectionRegistration::new(cache.clone(), "conn".to_string(), b.clone(), notify_tx); + let (registration, mut notify_rx) = register(&cache, "conn", &b); // Defining the id `b` references outdates `b`. cache @@ -1775,21 +2440,9 @@ mod tests { .lock() .unwrap() .commit_file(&toc, None, facts_xref("foo")); - // Register a connection whose current file is `current_file`, returning - // its registration and the receiver of its notifications. - let register = |connection_id: &str, current_file: &PathBuf| { - let (notify_tx, notify_rx) = mpsc::unbounded_channel(); - let registration = ConnectionRegistration::new( - cache.clone(), - connection_id.to_string(), - current_file.clone(), - notify_tx, - ); - (registration, notify_rx) - }; - let (_definer, mut definer_rx) = register("definer", &a); - let (_bystander, mut bystander_rx) = register("bystander", &b); - let (_toc_viewer, mut toc_viewer_rx) = register("toc_viewer", &toc); + let (_definer, mut definer_rx) = register(&cache, "definer", &a); + let (_bystander, mut bystander_rx) = register(&cache, "bystander", &b); + let (_toc_viewer, mut toc_viewer_rx) = register(&cache, "toc_viewer", &toc); // Renaming the heading outdates the TOC. A connection whose current // file is the TOC must re-translate it as well. @@ -1819,35 +2472,553 @@ mod tests { } } + // Verify that an outdated file goes to the consumer unless a connection + // shows it, as its current file or as the TOC; a connection re-renders + // those itself. + #[test] + fn test_outdated_files_go_to_consumer() { + let cache = Arc::new(Mutex::new(Cache::default())); + let (consumer_tx, consumer_rx) = std_mpsc::channel(); + cache.lock().unwrap().consumer_tx = Some(consumer_tx); + let toc = PathBuf::from("toc.md"); + let a = PathBuf::from("a.md"); + let b = PathBuf::from("b.md"); + let c = PathBuf::from("c.md"); + for referrer in [&toc, &b, &c] { + cache + .lock() + .unwrap() + .commit_file(referrer, None, facts_xref("foo")); + } + + // With no connections, the consumer reprocesses every outdated file, + // the TOC included. + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Foo")); + let mut sent = drain(&consumer_rx); + sent.sort_by_key(|message| format!("{message:?}")); + assert_eq!( + sent, + vec![ + ConsumerPathState::Outdated(b.clone()), + ConsumerPathState::Outdated(c.clone()), + ConsumerPathState::Outdated(toc.clone()), + ] + ); + + // A connection showing `c` re-renders `c` and the TOC; the consumer + // reprocesses only `b`. + let (_registration, mut notify_rx) = register(&cache, "conn", &c); + cache + .lock() + .unwrap() + .commit_file(&a, None, facts_target("foo", "Bar")); + assert_eq!(drain(&consumer_rx), vec![ConsumerPathState::Outdated(b)]); + let mut notifications = vec![notify_rx.try_recv().unwrap(), notify_rx.try_recv().unwrap()]; + notifications.sort_by_key(|notification| format!("{notification:?}")); + assert_eq!( + notifications, + vec![ + CacheNotification::CurrentFileOutdated, + CacheNotification::TocOutdated + ] + ); + } + + // Verify that the `CacheMap` holds `LRU_ENTRIES_PER_CONNECTION` caches per + // connection, plus one. + #[test] + fn test_cache_map_capacity() { + assert_eq!(cache_map_capacity(0).get(), 1); + assert_eq!( + cache_map_capacity(3).get(), + 3 * LRU_ENTRIES_PER_CONNECTION + 1 + ); + } + + // ### Watcher tests + // + // Verify that watcher events become the paths the walker receives: renames + // become a deletion then a creation, a modified directory is skipped, and + // lost events request a walk of the entire project. + #[test] + fn test_watcher_path_states() { + use WatcherPathState::{CreatedOrModified, Deleted}; + + let (temp_dir, root) = prep_test_dir!(); + let file = root.join("file.md"); + fs::write(&file, "").unwrap(); + let missing = root.join("missing.md"); + let translate = |kind: EventKind, event_paths: &[&PathBuf]| { + let event = event_paths.iter().fold(Event::new(kind), |event, path| { + event.add_path((*path).clone()) + }); + watcher_path_states(&event, &root) + }; + + assert_eq!( + translate(EventKind::Create(CreateKind::File), &[&file]), + vec![CreatedOrModified(file.clone())] + ); + assert_eq!( + translate(EventKind::Remove(RemoveKind::Any), &[&missing]), + vec![Deleted(missing.clone())] + ); + assert_eq!( + translate( + EventKind::Modify(ModifyKind::Name(RenameMode::Both)), + &[&missing, &file] + ), + vec![Deleted(missing.clone()), CreatedOrModified(file.clone())] + ); + assert_eq!( + translate( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &[&missing] + ), + vec![Deleted(missing.clone())] + ); + assert_eq!( + translate( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &[&file] + ), + vec![CreatedOrModified(file.clone())] + ); + // An unclassified rename is resolved by checking for the path. + assert_eq!( + translate( + EventKind::Modify(ModifyKind::Name(RenameMode::Any)), + &[&file, &missing] + ), + vec![CreatedOrModified(file.clone()), Deleted(missing.clone())] + ); + assert_eq!( + translate( + EventKind::Modify(ModifyKind::Data(DataChange::Any)), + &[&file, &root] + ), + vec![CreatedOrModified(file.clone())] + ); + assert_eq!( + translate(EventKind::Access(AccessKind::Any), &[&file]), + vec![] + ); + let rescan = Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert_eq!( + watcher_path_states(&rescan, &root), + vec![CreatedOrModified(root.clone())] + ); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + // ### Walker tests // - // Verify that the walker finds every file in a nested tree except those - // which are hidden or excluded by an `.ignore` file, and that a single-file - // root yields just that file. + // Verify that the walker forwards only those paths its ignore files don't + // exclude, walks each directory reported, and re-walks after an ignore file + // changes. The fixture holds hidden files, and files excluded by an + // `.ignore` file. #[test] fn test_walk_project() { let (temp_dir, test_dir) = prep_test_dir!(); - let a = test_dir.join("a.md"); - let b = test_dir.join("sub").join("b.py"); + let root = canonicalize(&test_dir).unwrap(); + let a = root.join("a.md"); + let ignored = root.join("ignored.md"); + let b = root.join("sub").join("b.py"); + let sub_ignored = root.join("sub").join("ignored.md"); // Confirm that copying the fixture preserved the hidden files, so their // absence from the walk below is the walker's doing. - assert!(test_dir.join(".ignore").is_file()); - assert!(test_dir.join(".hidden").join("c.md").is_file()); - - // Walk synchronously, returning the sorted list of files found. - let walk = |path: &Path| { - let (found_tx, mut found_rx) = mpsc::unbounded_channel(); - walk_project(path, &found_tx); - let mut found = vec![]; - while let Ok(path) = found_rx.try_recv() { - found.push(path); + assert!(root.join(".ignore").is_file()); + assert!(root.join(".hidden").join("c.md").is_file()); + + let (consumer_tx, consumer_rx) = std_mpsc::channel(); + let mut walker = Walker::new(root.clone(), consumer_tx); + // Process one path from the watcher, returning everything the walker + // sends the consumer as a result. The order in which a walk finds files + // varies by platform, so sort the files found. + let mut process = |path_state: WatcherPathState| { + walker.process(path_state).unwrap(); + let mut sent = drain(&consumer_rx); + if let [ + ConsumerPathState::WalkStarted(_), + files @ .., + ConsumerPathState::WalkFinished, + ] = sent.as_mut_slice() + { + files.sort_by_key(|message| format!("{message:?}")); } - found.sort(); - found + sent }; - assert_eq!(walk(&test_dir), vec![a.clone(), b]); - assert_eq!(walk(&a), vec![a]); + // The messages a walk of `dir` which finds `files` sends. + let walk = |dir: &PathBuf, files: &[&PathBuf]| { + let mut sent = vec![ConsumerPathState::WalkStarted(dir.clone())]; + let mut files: Vec<&PathBuf> = files.to_vec(); + files.sort(); + sent.extend(files.into_iter().map(|file| found(file))); + sent.push(ConsumerPathState::WalkFinished); + sent + }; + + // The initial walk skips hidden and ignored files. + assert_eq!( + process(WatcherPathState::CreatedOrModified(root.clone())), + walk(&root, &[&a, &b]) + ); + + // A modified file is forwarded, unless it's ignored or hidden. + assert_eq!( + process(WatcherPathState::CreatedOrModified(a.clone())), + vec![found(&a)] + ); + assert_eq!( + process(WatcherPathState::CreatedOrModified(ignored.clone())), + vec![] + ); + assert_eq!( + process(WatcherPathState::CreatedOrModified( + root.join(".hidden").join("c.md") + )), + vec![] + ); + + // Likewise for a deleted path. + let gone = root.join("gone.md"); + assert_eq!( + process(WatcherPathState::Deleted(gone.clone())), + vec![ConsumerPathState::WatcherPathState( + WatcherPathState::Deleted(gone) + )] + ); + assert_eq!(process(WatcherPathState::Deleted(ignored.clone())), vec![]); + + // A created directory is walked, unless it's hidden. + let new_dir = root.join("new_dir"); + let d = new_dir.join("d.md"); + let new_dir_ignored = new_dir.join("ignored.md"); + fs::create_dir(&new_dir).unwrap(); + fs::write(&d, "# d.md").unwrap(); + fs::write(&new_dir_ignored, "# Ignored.").unwrap(); + assert_eq!( + process(WatcherPathState::CreatedOrModified(new_dir.clone())), + walk(&new_dir, &[&d]) + ); + assert_eq!( + process(WatcherPathState::CreatedOrModified(root.join(".hidden"))), + vec![] + ); + + // An ignore file inside an ignored directory can't change what the + // walk finds, so it prompts no walk. + let hidden_ignore = root.join(".hidden").join(".ignore"); + fs::write(&hidden_ignore, "c.md\n").unwrap(); + assert_eq!( + process(WatcherPathState::CreatedOrModified(hidden_ignore)), + vec![] + ); + + // Changing an `.ignore` file reloads the ignore files, then re-walks + // the directory it governs. + fs::write(root.join(".ignore"), "a.md\n").unwrap(); + assert_eq!( + process(WatcherPathState::CreatedOrModified(root.join(".ignore"))), + walk(&root, &[&ignored, &b, &sub_ignored, &d, &new_dir_ignored]) + ); + assert_eq!( + process(WatcherPathState::CreatedOrModified(a.clone())), + vec![] + ); + + // Git's ignore files apply only within a repository. + fs::create_dir_all(root.join(".git").join("info")).unwrap(); + fs::write(new_dir.join(".gitignore"), "d.md\n").unwrap(); + assert_eq!( + process(WatcherPathState::CreatedOrModified( + new_dir.join(".gitignore") + )), + walk(&new_dir, &[&new_dir_ignored]) + ); + // A change to `.git/info/exclude` re-walks the entire project. + let exclude = root.join(".git").join("info").join("exclude"); + fs::write(&exclude, "b.py\n").unwrap(); + assert_eq!( + process(WatcherPathState::CreatedOrModified(exclude)), + walk(&root, &[&ignored, &sub_ignored, &new_dir_ignored]) + ); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + + // ### Consumer tests + // + // Verify that the consumer scans the files the walker sends into the cache, + // rescanning one only when it's changed or outdated, and leaves a + // connection's current file to that connection. + #[test] + fn test_consumer_scans() { + let (temp_dir, test_dir) = prep_test_dir!(); + let root = canonicalize(&test_dir).unwrap(); + let a = root.join("a.md"); + let b = root.join("b.md"); + fs::write(&a, "

Foo

\n").unwrap(); + fs::write(&b, "See .\n").unwrap(); + let cache = Arc::new(Mutex::new(Cache::new(root.clone()))); + let (consumer_tx, consumer_rx) = std_mpsc::channel(); + cache.lock().unwrap().consumer_tx = Some(consumer_tx); + let mut consumer = Consumer::default(); + let inner_html = |cache: &Arc>| { + cache.lock().unwrap().files[&a].targets["foo"] + .inner_html + .clone() + }; + + consumer.apply(&cache, found(&a)); + consumer.apply(&cache, found(&b)); + assert_eq!(inner_html(&cache), "Foo"); + assert_eq!(*dependents(&cache.lock().unwrap(), "foo"), paths(&[&b])); + assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::UpToDate); + + // An unmodified file isn't rescanned. To observe this, alter its cached + // target, which a rescan would restore. + cache + .lock() + .unwrap() + .files + .get_mut(&a) + .unwrap() + .targets + .get_mut("foo") + .unwrap() + .inner_html = "Stale".to_string(); + consumer.apply(&cache, found(&a)); + assert_eq!(inner_html(&cache), "Stale"); + + // A modified file is rescanned, which outdates the file referencing it; + // the consumer is sent that file. + fs::write(&a, "

Foo bar

\n").unwrap(); + consumer.apply(&cache, found(&a)); + assert_eq!(inner_html(&cache), "Foo bar"); + assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::Outdated); + assert_eq!( + drain(&consumer_rx), + vec![ConsumerPathState::Outdated(b.clone())] + ); + // Scanning the outdated file brings it up to date; a second request, + // as when several commits outdate one file, is skipped. To observe + // this, discard its cached metadata, which a rescan would restore. + consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); + assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::UpToDate); + cache.lock().unwrap().files.get_mut(&b).unwrap().metadata = None; + consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); + assert!(cache.lock().unwrap().files[&b].metadata.is_none()); + + // A connection's current file isn't scanned... + let (registration, mut notify_rx) = register(&cache, "conn", &a); + fs::write(&a, "

Foo baz

\n").unwrap(); + consumer.apply(&cache, found(&a)); + assert_eq!(inner_html(&cache), "Foo bar"); + // ...nor forgotten when it's deleted: the connection's IDE may still + // hold its text, so the connection is told to re-translate it. + fs::remove_file(&a).unwrap(); + consumer.apply( + &cache, + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(a.clone())), + ); + assert!(cache.lock().unwrap().files.contains_key(&a)); + assert_eq!( + notify_rx.try_recv(), + Ok(CacheNotification::CurrentFileOutdated) + ); + // Once no connection shows the file, the consumer is asked to check + // it; this finds it gone, so it's forgotten, which outdates the file + // referencing it. + drop(registration); + assert_eq!( + drain(&consumer_rx), + vec![ConsumerPathState::Released(a.clone())] + ); + consumer.apply(&cache, ConsumerPathState::Released(a.clone())); + assert!(!cache.lock().unwrap().files.contains_key(&a)); + assert_eq!( + drain(&consumer_rx), + vec![ConsumerPathState::Outdated(b.clone())] + ); + + // A file which isn't text, or which no lexer recognizes, defines + // nothing; it's cached as such, so that it isn't read again until it + // changes. One with a lexer directive is translated, whatever its + // extension. + let binary = root.join("binary.md"); + let unknown = root.join("unknown.xyz"); + let directive = root.join("directive.xyz"); + fs::write(&binary, [0xff, 0xfe, 0x00]).unwrap(); + fs::write(&unknown, "

Unknown

\n").unwrap(); + fs::write( + &directive, + "# CodeChat Editor lexer: python\n#

Directive

\n", + ) + .unwrap(); + for path in [&binary, &unknown, &directive] { + consumer.apply(&cache, found(path)); + } + { + let cache_guard = cache.lock().unwrap(); + for path in [&binary, &unknown] { + assert!(cache_guard.files[path].targets.is_empty()); + assert_eq!(*status(&cache_guard, path), FileStatus::UpToDate); + } + assert!(matches!( + cache_guard.resolve_id("unknown"), + IdResolution::Missing + )); + assert!(matches!( + cache_guard.resolve_id("directive"), + IdResolution::Target { .. } + )); + } + // A file which stops being translatable withdraws its definitions. + fs::write(&directive, "#

Directive

\n").unwrap(); + consumer.apply(&cache, found(&directive)); + assert!(matches!( + cache.lock().unwrap().resolve_id("directive"), + IdResolution::Missing + )); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + + // Verify that the consumer forgets deleted files and directories, and the + // cached files a walk doesn't find. + #[test] + fn test_consumer_forgets() { + let (temp_dir, test_dir) = prep_test_dir!(); + let root = canonicalize(&test_dir).unwrap(); + let sub = root.join("sub"); + fs::create_dir(&sub).unwrap(); + let a = root.join("a.md"); + let b = sub.join("b.md"); + let c = sub.join("c.md"); + let d = sub.join("d.md"); + for path in [&a, &b, &c, &d] { + fs::write(path, "# Text\n").unwrap(); + } + let cache = Arc::new(Mutex::new(Cache::new(root.clone()))); + let mut consumer = Consumer::default(); + for path in [&a, &b, &c, &d] { + consumer.apply(&cache, found(path)); + } + let cached = |cache: &Arc>| { + cache + .lock() + .unwrap() + .files + .keys() + .cloned() + .collect::>() + }; + assert_eq!( + cached(&cache), + vec![a.clone(), b.clone(), c.clone(), d.clone()] + ); + + // A walk which doesn't find a cached file means it no longer exists. + // Files outside the directory walked are unaffected. + fs::remove_file(&c).unwrap(); + consumer.apply(&cache, ConsumerPathState::WalkStarted(sub.clone())); + consumer.apply(&cache, found(&b)); + consumer.apply(&cache, found(&d)); + consumer.apply(&cache, ConsumerPathState::WalkFinished); + assert_eq!(cached(&cache), vec![a.clone(), b.clone(), d.clone()]); + + // A file a connection stopped showing during a walk wasn't found by + // that walk, so the walk still forgets it -- as it should if, say, the + // walk skipped the file as ignored. + consumer.apply(&cache, ConsumerPathState::WalkStarted(sub.clone())); + consumer.apply(&cache, found(&b)); + consumer.apply(&cache, ConsumerPathState::Released(d.clone())); + consumer.apply(&cache, ConsumerPathState::WalkFinished); + assert_eq!(cached(&cache), vec![a.clone(), b.clone()]); + + // A deletion reported for a path which exists again, as after a + // version control checkout, rescans the file rather than forgetting + // it. + fs::write(&a, "

Recreated

\n").unwrap(); + consumer.apply( + &cache, + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(a.clone())), + ); + assert_eq!(cached(&cache), vec![a.clone(), b.clone()]); + assert!(matches!( + cache.lock().unwrap().resolve_id("recreated"), + IdResolution::Target { .. } + )); + + // A deleted directory takes its files with it. + fs::remove_dir_all(&sub).unwrap(); + consumer.apply( + &cache, + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(sub)), + ); + assert_eq!(cached(&cache), vec![a.clone()]); + + // As does a deleted file. + fs::remove_file(&a).unwrap(); + consumer.apply( + &cache, + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(a)), + ); + assert!(cached(&cache).is_empty()); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + + // Verify the whole pipeline: a project's cache learns of its files through + // the initial walk, then of files created and deleted afterward through + // the watcher. + #[test] + fn test_new_project() { + // Wait for `condition` to hold, since the threads which maintain the + // cache run on their own schedule. The watcher debounces events for 2 + // seconds, which this timeout comfortably exceeds. + fn wait_for(condition: impl Fn() -> bool) { + let start = Instant::now(); + while !condition() { + assert!( + start.elapsed() < Duration::from_secs(30), + "timed out waiting for the cache" + ); + sleep(Duration::from_millis(50)); + } + } + + let (temp_dir, test_dir) = prep_test_dir!(); + let root = canonicalize(&test_dir).unwrap(); + let a = root.join("a.md"); + let b = root.join("b.md"); + fs::write(&a, "

Foo

\n").unwrap(); + let cache = Cache::new_project(root.clone()); + let is_cached = |path: &Path| cache.lock().unwrap().files.contains_key(path); + + wait_for(|| is_cached(&a)); + fs::write(&b, "See .\n").unwrap(); + wait_for(|| is_cached(&b)); + assert_eq!(*dependents(&cache.lock().unwrap(), "foo"), paths(&[&b])); + fs::remove_file(&a).unwrap(); + wait_for(|| !is_cached(&a)); + assert!(matches!( + cache.lock().unwrap().resolve_id("foo"), + IdResolution::Missing + )); + // Dropping the cache stops its watcher, so that the temporary directory + // can be removed. + drop(cache); // Report any errors produced when removing the temporary directory. temp_dir.close().unwrap(); } diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs index f5097e0d..cc638934 100644 --- a/server/src/processing/tests.rs +++ b/server/src/processing/tests.rs @@ -1977,6 +1977,62 @@ fn test_hydrate_fragment_in_markdown() { ); } +// Verify that a fragment without an id -- whether its `id` attribute is missing +// or empty -- hydrates to an error message requesting the id. Dehydration +// removes the message, so saving the hydrated result writes back a bare +// ``. Since a fragment is cached under its id, the cache +// records nothing for one which lacks an id. +#[test] +fn test_hydrate_fragment_without_id() { + // Both cases produce the same output. Hydration sees the empty `id`, which + // it must treat as missing rather than as an invalid id; the minifier then + // discards the empty attribute as redundant, so the Client never receives + // it and saving can't restore it. + for fragment in ["", ""] { + let cache = Arc::new(Mutex::new(Cache::default())); + let translation = source_to_codechat_for_web( + &format!("# {fragment}Doc.\nx = 1\n"), + Path::new("foo.py"), + 0.0, + false, + Some(cache.clone()), + ) + .unwrap(); + assert_eq!( + translation, + build_codechat_for_web( + "python", + "\nx = 1\n", + vec![build_codemirror_doc_block( + 0, + 1, + "", + "#", + "

a fragment requires an \"id\" attributeDoc." + )] + ), + "hydrating {fragment}" + ); + assert_eq!( + codechat_for_web_to_source(&translation).unwrap(), + "# Doc.\nx = 1\n", + "saving {fragment}" + ); + // The commit recorded the file, but neither a fragment in it nor any + // id. + let cache_guard = cache.lock().unwrap(); + assert_eq!(cache_guard.files.len(), 1, "caching {fragment}"); + assert!( + cache_guard + .files + .values() + .all(|file_entry| file_entry.fragments.is_empty()), + "caching {fragment}" + ); + assert!(cache_guard.ids.is_empty(), "caching {fragment}"); + } +} + // Verify auto-assignment of ids: `id="*"` is replaced by a generated, valid CSS // identifier which is *not* recorded in the cache, and which the cache picks up // only once the file carrying it is written and processed again. diff --git a/server/src/translation.rs b/server/src/translation.rs index 572f0be4..7826c301 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -248,7 +248,7 @@ use crate::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString, UNICODE_CURSOR_MARKER, byte_index_of, - cache::{CacheMap, CacheNotification, ConnectionRegistration}, + cache::{CacheMap, CacheNotification, ConnectionRegistration, cache_map_capacity}, codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, find_path_to_toc, minify, project_cache, remove_tinymce_data, source_to_codechat_for_web_string, transform_html, @@ -489,6 +489,23 @@ struct TranslationTask { capture_context: CaptureContext, } +/// Resize the map of project caches to suit the number of connections, each of +/// which has a translation task listed in `processing_task_queue_tx`; see +/// `cache_map_capacity`. +fn resize_cache_map( + // The state holding the map and the list of translation tasks. + app_state: &WebAppState, +) { + // Hold the list's lock while resizing, so that connections which start or + // stop at the same time can't apply their counts out of order. + let processing_task_queue_tx = app_state.processing_task_queue_tx.lock().unwrap(); + app_state + .cache + .lock() + .unwrap() + .resize(cache_map_capacity(processing_task_queue_tx.len())); +} + /// This is the processing task for the Visual Studio Code IDE. It handles all /// the core logic to moving data between the IDE and the client. #[allow(clippy::too_many_arguments)] @@ -516,6 +533,7 @@ pub async fn translation_task( .lock() .unwrap() .insert(connection_id.clone(), from_http_tx); + resize_cache_map(&app_state); let (cache_notify_tx, cache_notify_rx) = mpsc::unbounded_channel(); let mut continue_loop = true; @@ -754,6 +772,7 @@ pub async fn translation_task( { error!("Unable to remove connection ID {connection_id} from processing task queue."); } + resize_cache_map(&app_state); if app_state .client_queues .lock() diff --git a/server/src/webserver.rs b/server/src/webserver.rs index add5899d..37f87728 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -24,7 +24,6 @@ pub mod tests; // ------- // // ### Standard library -use core::num::NonZeroUsize; use std::{ borrow::Cow, collections::{HashMap, HashSet}, @@ -89,7 +88,7 @@ use url::Url; //use crate::capture::EventCapture; use crate::processing::{ CodeChatForWeb, SourceToCodeChatForWebError, TranslationResultsString, - cache::{CacheMap, LRU_ENTRIES_PER_CONNECTION}, + cache::{CacheMap, cache_map_capacity}, find_path_to_toc, source_to_codechat_for_web_string, }; @@ -1707,9 +1706,9 @@ fn make_app_data_with_capture_spool( connection_id: Mutex::new(HashSet::new()), credentials, capture, - cache: Arc::new(Mutex::new(LruCache::new( - NonZeroUsize::new(LRU_ENTRIES_PER_CONNECTION).unwrap(), - ))), + // There are no connections yet; `translation_task` resizes this as + // they come and go. + cache: Arc::new(Mutex::new(LruCache::new(cache_map_capacity(0)))), }) } From 3e3aef84c918dd803027fc515d5bd0255220bd00 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 9 Oct 2026 09:39:02 -0500 Subject: [PATCH 10/14] wip: cache. --- server/src/ide/vscode/tests.rs | 48 ++- server/src/processing.rs | 10 +- server/src/processing/cache-spec.md | 49 ++- server/src/processing/cache.rs | 382 ++++++++++++++---- server/src/translation.rs | 121 +++++- .../tests/test_vscode_ide_websocket10/toc.md | 2 +- 6 files changed, 499 insertions(+), 113 deletions(-) diff --git a/server/src/ide/vscode/tests.rs b/server/src/ide/vscode/tests.rs index 66d8df54..a7063a1c 100644 --- a/server/src/ide/vscode/tests.rs +++ b/server/src/ide/vscode/tests.rs @@ -1732,8 +1732,9 @@ fn ide_update( } /// Verify that the Server refreshes the TOC in the Client's sidebar when its -/// project's cache reports the TOC outdated: the TOC cross-references a heading -/// in the current file, which the IDE then renames. +/// project's cache reports the TOC outdated -- the TOC cross-references a +/// heading in the current file, which the IDE then renames -- or changed on +/// disk. #[actix_web::test] async fn test_vscode_ide_websocket11() { let connection_id = "test-connection-id11"; @@ -1858,6 +1859,49 @@ async fn test_vscode_ide_websocket11() { ) .await; + // Change the TOC on disk. Once the cache's watcher reports this, the Server + // re-loads the TOC, asking the IDE for it first... + fs::write( + test_dir.join("toc.md"), + "* \n* More\n", + ) + .unwrap(); + let em = read_message(&mut ws_ide).await; + let (path, is_current) = cast!( + em.message, + EditorMessageContents::LoadFile, + path, + is_current + ); + assert_eq!(path.file_name().unwrap(), "toc.md"); + assert!(!is_current); + assert_eq!(em.id, INITIAL_MESSAGE_ID + 5.0 * MESSAGE_ID_INCREMENT); + send_message( + &mut ws_ide, + &EditorMessage { + id: INITIAL_MESSAGE_ID + 5.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::Result(Ok(ResultOkTypes::LoadFile(None))), + }, + ) + .await; + // ...then, since the IDE doesn't have it, from disk. + assert_eq!( + read_message(&mut ws_client).await, + EditorMessage { + id: INITIAL_MESSAGE_ID + 6.0 * MESSAGE_ID_INCREMENT, + message: EditorMessageContents::UpdateToc( + "

" + .to_string() + ), + } + ); + acknowledge( + &mut ws_client, + &mut ws_ide, + INITIAL_MESSAGE_ID + 6.0 * MESSAGE_ID_INCREMENT, + ) + .await; + check_logger_errors(0); // Report any errors produced when removing the temporary directory. temp_dir.close().unwrap(); diff --git a/server/src/processing.rs b/server/src/processing.rs index e362046f..fed26e03 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -82,7 +82,7 @@ use crate::{ processing::cache::{FileFacts, FragmentFact, IdResolution, TargetFact}, webserver::canonicalize, }; -use cache::{Cache, CacheMap}; +use cache::{Cache, CacheMap, get_or_insert_cache}; // Data structures // --------------- @@ -437,11 +437,9 @@ pub fn project_cache( .parent() .map_or_else(PathBuf::new, Path::to_path_buf); let root = canonicalize(&root).unwrap_or(root); - cache_map - .lock() - .unwrap() - .get_or_insert_ref(&root, || Cache::new_project(root.clone())) - .clone() + get_or_insert_cache(&mut cache_map.lock().unwrap(), &root, || { + Cache::new_project(root.clone()) + }) } #[derive(Debug, thiserror::Error)] diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 2f4fe90b..57fe9f5d 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -91,10 +91,10 @@ Details: exclusion is what keeps a gather element and the fragments it lists from outdating each other forever. The HTML rendering of a fragment reproduces the layout of the source it came from: each doc block includes its indent, each - line of a code block or doc block is preceded by that line's number, and the - two are aligned -- a doc block and a line of code indented equally in the - source begin in the same column, with the line numbers in a gutter of their - own to the left of both. + line of a code block or the first line of a doc block is preceded by that + line's number, and the two are aligned -- a doc block and a line of code + indented equally in the source begin in the same column, with the line numbers + in a gutter of their own to the left of both. * The backlinks a `` hydrates to are derived from the gather elements which list it: for each such element, its containing file, its `id`, and its inner HTML (the link text). A fragment's rendered output therefore depends on @@ -357,9 +357,15 @@ A consumer reads paths from the receiver paired to the `Cache::consumer_tx` channel. It holds a weak reference to the cache, and exits when this channel is closed or the weak reference can't be upgraded. It is implemented as a thread. -* For a `WatcherPathState`: if the file exists and differs from its cached - metadata's modification time and length, perform the following steps. For an - `Outdated`, always perform the following steps: +* If the file exists and differs from its cached metadata's modification time + and length or if the file's status is `Outdated`: + + * If the file is the current file for any of the cache's connection, ignore + it. This is handled by the standard IDE to Client translation mechanism. + + * If the file is the TOC, send `TocChanged` or `TocOutdated` to all + connections. The file will be translated and updated in the cache when that + message is processed. * If not translatable, the file is still added to the cache, to prevent reprocessing if the directory is re-walked. @@ -372,16 +378,15 @@ closed or the weak reference can't be upgraded. It is implemented as a thread. and may rewrite files open in the IDE, motivating the choice of a user-selected option. - If processing a file marks any connection's current file or its TOC as - outdated or deleted, the connection(s) are sent a `CurrentFileOutdated` - and/or `TocOutdated`/`TocChanged` message(s) to retranslate the file/IDE - contents; otherwise, the `Outdated(file)` message is sent to itself via the - consumer TX channel for processing. Note that `TocOutdated`/`TocChanged` - messages are sent to all the cache's connections, since they share the same - project, while `CurrentFileOutdated` is only sent to multiple connections is - it being simultaneously edited. If the TOC is the current file, both - messages are sent. `TocChanged` is sent if the TOC was changed, while - `TocOutdated` is sent when the TOC is only outdated. + Translating the file produces zero or more outdated files. For each outdated + file, mark its status as `Outdated` then: + + 1. If the outdated file is the project TOC, then send `TocOutdated` to all + connections. + 2. If the outdated file is the current file for one or more connections, + then send `CurrentFileOutdated` to those connections. + 3. If neither 1 or 2 is true, the `Outdated(file)` message is sent to itself + via the consumer TX channel for later processing. * If the path doesn't exist, search the cache for it; if found, remove it. Otherwise, assume the path was a directory. Search the cache for all files in this directory, removing them. @@ -414,11 +419,11 @@ trigger another. Instead, edits to the TOC reach the sidebar in other ways: * When a connection's current file is the TOC, the connection refreshes its sidebar after each edit to the TOC, whether made in the IDE or the Client. -* TODO: once the consumer thread exists, have it notify every connection in the - project when the TOC changes on disk. Since the TOC's text then differs from - the text the sidebar last loaded, this needs a notification other than - `TocOutdated`, prompting the connection to fetch the TOC's new text from its - IDE (with a `LoadFile`), falling back to disk, before re-rendering it. +* When the TOC changes on disk, the consumer sends `TocChanged` to every + connection in the project. Since the TOC's text then differs from the text the + sidebar last loaded, this prompts the connection to fetch the TOC's new text + from its IDE (with a `LoadFile`), falling back to disk, before re-rendering + it. ### Shutdown diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index 20158c6c..a108e199 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -60,8 +60,15 @@ use super::{SourceToCodeChatForWebError, source_to_codechat_for_web}; /// The set of all project caches, keyed by each project's root directory (the /// directory containing its `toc.md`). The outer mutex guards the addition of /// newly-discovered projects; each inner mutex guards a single project's cache, -/// so that work on one project doesn't block work on another. -pub type CacheMap = Arc>>>>; +/// so that work on one project doesn't block work on another. When both are +/// needed, lock the outer mutex first. +pub type CacheMap = Arc>; + +/// The contents of a `CacheMap`. Insert into and resize it only through +/// `get_or_insert_cache` and `set_cache_map_capacity`, which evict the least +/// recently used cache when full -- but never a cache in which a connection is +/// registered. +pub type ProjectCaches = LruCache>>; /// The number of project caches the `CacheMap` holds per connection; see /// `cache_map_capacity`. @@ -354,7 +361,7 @@ struct ConnectionView { } /// Notifications the cache sends to a registered connection. -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CacheNotification { /// A change elsewhere in the project outdated the rendered output of the /// connection's current file, which the connection should re-translate. @@ -363,14 +370,19 @@ pub enum CacheNotification { /// project's TOC, which every connection in the project shows in its /// sidebar; the connection should re-render it. TocOutdated, - /// The contents of the TOC changed; the connection should re-load then re-render it. (Since the IDE is editing the current file, changes made to it are superseded by the IDE content, so that a `CurrentFileChanged` message is meaningless. + /// The contents of the TOC changed on disk, or the TOC was deleted; the + /// connection should re-load then re-render it. (Since the IDE is editing + /// the current file, changes made to it on disk are superseded by the IDE + /// content, so that a `CurrentFileChanged` message is meaningless.) TocChanged, } /// Lists a connection in `Cache::connections` for as long as this value lives; /// dropping it removes the listing. pub struct ConnectionRegistration { - /// The cache the connection is listed in. + /// The cache the connection is listed in. This is weak, so that only the + /// `CacheMap` keeps a cache alive; the map in turn never evicts a cache + /// while a connection is listed in it. cache: Weak>, /// The connection's key in `Cache::connections`. connection_id: String, @@ -391,6 +403,77 @@ pub fn cache_map_capacity( NonZeroUsize::MIN.saturating_add(connections.saturating_mul(LRU_ENTRIES_PER_CONNECTION)) } +/// Return the cache of the project rooted at `root`, first creating it if the +/// `CacheMap` has none. +pub fn get_or_insert_cache( + // The contents of the locked `CacheMap`. + caches: &mut ProjectCaches, + // The project's root directory, as a canonicalized absolute path. + root: &Path, + // Creates the project's cache, if the map has none. + create: impl FnOnce() -> Arc>, + // The project's cache. +) -> Arc> { + if let Some(cache) = caches.get(root) { + return cache.clone(); + } + // Inserting into a full map evicts its least recently used cache, even one + // with connections, so make room first. + evict_unconnected(caches, caches.cap().get() - 1); + if caches.len() == caches.cap().get() { + caches.resize(caches.cap().saturating_add(1)); + } + let cache = create(); + caches.put(root.to_path_buf(), cache.clone()); + cache +} + +/// Set the capacity of the `CacheMap`; `translation_task` applies +/// `cache_map_capacity` as connections come and go. +pub fn set_cache_map_capacity( + // The contents of the locked `CacheMap`. + caches: &mut ProjectCaches, + // The new capacity. + capacity: NonZeroUsize, +) { + evict_unconnected(caches, capacity.get()); + // Shrinking the map evicts its least recently used caches, even those with + // connections, so keep room for any which remain. + caches.resize(capacity.max(NonZeroUsize::new(caches.len()).unwrap_or(capacity))); +} + +/// Evict the least recently used caches in which no connection is registered, +/// until the `CacheMap` holds no more than `capacity` caches. Per the spec, a +/// cache with connections is never evicted: a `ConnectionRegistration` holds +/// only a weak reference to its cache, so evicting the cache would drop it +/// while its connections still depend on it. Since a connection registers with +/// at most one cache, and the map's capacity exceeds the number of connections +/// (see `cache_map_capacity`), this normally leaves the map within `capacity`. +fn evict_unconnected( + // The contents of the locked `CacheMap`. + caches: &mut ProjectCaches, + // The number of caches to keep, if possible. + capacity: usize, +) { + let excess = caches.len().saturating_sub(capacity); + let evicted: Vec = caches + .iter() + .rev() + .filter(|(_, cache)| { + cache + .lock() + .unwrap_or_else(PoisonError::into_inner) + .connections + .is_empty() + }) + .map(|(root, _)| root.clone()) + .take(excess) + .collect(); + for root in evicted { + caches.pop(&root); + } +} + impl Cache { /// Create an empty cache with no consumer to keep it current: the cache of /// a single-file "project", which consists only of that file, or of a @@ -807,7 +890,10 @@ impl Cache { entry.status = FileStatus::Outdated; } } - self.notify_connections(outdated); + self.notify_current_file_outdated(outdated); + if outdated.contains(&self.toc_path()) { + self.notify_toc(CacheNotification::TocOutdated); + } if let Some(consumer_tx) = &self.consumer_tx { for path in outdated.iter().filter(|path| !self.is_shown(path)) { // A send fails only once the consumer has exited, when this @@ -817,26 +903,37 @@ impl Cache { } } - /// Notify each connection showing one of these files -- as its current - /// file, or as the TOC in its sidebar -- so that the connection re-renders - /// the file. - fn notify_connections( + /// Notify each connection whose current file is one of these files, so + /// that the connection re-translates it. Several connections may share a + /// current file, if it's being edited in each simultaneously. + fn notify_current_file_outdated( &self, // The files whose rendered output is outdated. outdated: &HashSet, ) { - let is_toc_outdated = outdated.contains(&self.toc_path()); for connection in self.connections.values() { - // A send fails only once the connection's translation task has - // exited, leaving nothing to notify. if outdated.contains(&connection.current_file) { + // A send fails only once the connection's translation task has + // exited, leaving nothing to notify. let _ = connection .notify_tx .send(CacheNotification::CurrentFileOutdated); } - if is_toc_outdated { - let _ = connection.notify_tx.send(CacheNotification::TocOutdated); - } + } + } + + /// Notify every connection about the TOC, which every connection in the + /// project shows in its sidebar. + fn notify_toc( + &self, + // `TocOutdated` if only the TOC's rendering is outdated; `TocChanged` + // if its text changed. + notification: CacheNotification, + ) { + for connection in self.connections.values() { + // A send fails only once the connection's translation task has + // exited, leaving nothing to notify. + let _ = connection.notify_tx.send(notification); } } @@ -890,14 +987,18 @@ impl Cache { /// Forget a file which no longer exists, or which the walker now ignores /// -- unless a connection shows it. The connection's IDE may still hold the /// file's text, so instead have the connection re-translate that text, - /// which keeps the file in the cache. + /// which keeps the file in the cache; for the TOC, every connection + /// re-loads it. fn forget_file( &mut self, // The file to forget. path: &Path, ) { if self.is_shown(path) { - self.notify_connections(&HashSet::from([path.to_path_buf()])); + self.notify_current_file_outdated(&HashSet::from([path.to_path_buf()])); + if path == self.toc_path() { + self.notify_toc(CacheNotification::TocChanged); + } } else if self.files.contains_key(path) { self.remove_file(path); } @@ -1023,7 +1124,7 @@ impl ConnectionRegistration { #[must_use] pub fn new( // The cache of the project containing `current_file`. - cache: Arc>, + cache: &Arc>, // The connection's id, unique among all connections. connection_id: String, // The file shown in the connection's Client, as a canonicalized @@ -1040,7 +1141,7 @@ impl ConnectionRegistration { }, ); ConnectionRegistration { - cache, + cache: Arc::downgrade(cache), connection_id, } } @@ -1048,9 +1149,14 @@ impl ConnectionRegistration { impl Drop for ConnectionRegistration { fn drop(&mut self) { + // The `CacheMap` keeps a cache with connections alive, so this fails + // only once the map itself is gone, taking the listing with it. + let Some(cache) = self.cache.upgrade() else { + return; + }; // Tolerate a poisoned lock: this may run while unwinding from another // panic, where a second panic aborts the process. - let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner); let Some(connection) = cache.connections.remove(&self.connection_id) else { return; }; @@ -1142,9 +1248,6 @@ fn watch_then_walk( // still works, but won't learn of files which change after the walk. On // Linux, this happens once the per-user inotify limits are reached, since // each watcher uses its own inotify instance. - // - // TODO: inform the user of this failure, as the spec requires, once the - // Server can show the user a warning. let watcher = watch_project(&root, walker_tx.clone()) .inspect_err(|err| warn!("Unable to watch {}: {err}", root.display())) .ok(); @@ -1202,7 +1305,7 @@ fn watch_project( } } // A path which can't be watched isn't usable by the IDE either, so - // per the spec, report this only to the log. + // report this only to the log. Err(errors) => { for err in errors { warn!("Unable to watch {}: {err}", handler_root.display()); @@ -1573,18 +1676,17 @@ enum ScanOutcome { /// Bring the cache's record of a file up to date with the file on disk by /// translating the file to the CodeChatForWeb format, which commits its facts -/// to the cache. Per the spec, a connection's current file is skipped: the -/// connection keeps it up to date, from text which may be newer than the file -/// on disk. +/// to the cache. A connection's current file is skipped: the connection keeps +/// it up to date from its IDE's text, which supersedes the file on disk. fn scan_file( // The cache to update. cache: &Arc>, // The file to scan. path: &Path, // True if a commit outdated the file, whose content is unchanged; it's - // scanned unless it's been reprocessed since. False if the file itself may - // have changed; it's scanned unless its cached metadata's modification - // time and length match the file's. + // always scanned, unless the cache has since forgotten it. False if the + // file itself may have changed; it's scanned unless its cached metadata's + // modification time and length match the file's. is_outdated: bool, ) { // Perform I/O before locking the cache, so that the lock isn't held during @@ -1598,22 +1700,29 @@ fn scan_file( return; } }; + // True if the TOC's text differs from the text the cache last read, which + // a connection's sidebar may still show. When the cache has no record of + // the TOC, no sidebar can show it: loading it in a sidebar records it. + let is_toc_changed; { let cache = cache.lock().unwrap(); + let entry = cache.files.get(path); + let is_modified = entry + .and_then(|entry| entry.metadata.as_ref()) + .is_none_or(|cached| !is_unmodified(cached, &metadata)); + is_toc_changed = is_modified && entry.is_some() && path == cache.toc_path(); if cache.is_current_file(path) { + // The connections' sidebars show the TOC from their own IDE's text + // or from disk, so each must still re-load it. + if is_toc_changed { + cache.notify_toc(CacheNotification::TocChanged); + } return; } - let entry = cache.files.get(path); let is_up_to_date = if is_outdated { - entry.is_none_or(|entry| entry.status != FileStatus::Outdated) + entry.is_none() } else { - entry.is_some_and(|entry| { - entry.status == FileStatus::UpToDate - && entry - .metadata - .as_ref() - .is_some_and(|cached| is_unmodified(cached, &metadata)) - }) + !is_modified && entry.is_some_and(|entry| entry.status == FileStatus::UpToDate) }; if is_up_to_date { return; @@ -1655,31 +1764,34 @@ fn scan_file( // which may then have committed the file's content on disk over the // content the connection shows, which may be newer. Have the // connection re-translate its content to restore it. - cache.notify_connections(&HashSet::from([path.to_path_buf()])); - return; - } - match outcome { - // The translation's commit recorded the file's metadata as of the - // commit, which follows the reading of its text. A write in between - // would leave the cache describing the old text with the new metadata, - // so the consumer would skip the watcher's report of that write. - // Record the metadata read before the text instead, which any such - // write changes. - ScanOutcome::Translated => { - if let Some(entry) = cache.files.get_mut(path) { - entry.metadata = Some(metadata); + cache.notify_current_file_outdated(&HashSet::from([path.to_path_buf()])); + } else { + match outcome { + // The translation's commit recorded the file's metadata as of the + // commit, which follows the reading of its text. A write in + // between would leave the cache describing the old text with the + // new metadata, so the consumer would skip the watcher's report of + // that write. Record the metadata read before the text instead, + // which any such write changes. + ScanOutcome::Translated => { + if let Some(entry) = cache.files.get_mut(path) { + entry.metadata = Some(metadata); + } } + // A file which can't be translated defines nothing. Committing it + // as such withdraws whatever it defined before it stopped being + // translatable, and records its metadata, so that later walks skip + // it until it changes rather than reading it again. + ScanOutcome::Untranslatable => { + cache.commit_file(path, Some(metadata), FileFacts::default()); + } + // Leave the cache as it is: without metadata matching the file, + // the next walk tries again. + ScanOutcome::Failed => {} } - // A file which can't be translated defines nothing. Committing it as - // such withdraws whatever it defined before it stopped being - // translatable, and records its metadata, so that later walks skip it - // until it changes rather than reading it again. - ScanOutcome::Untranslatable => { - cache.commit_file(path, Some(metadata), FileFacts::default()); - } - // Leave the cache as it is: without metadata matching the file, the - // next walk tries again. - ScanOutcome::Failed => {} + } + if is_toc_changed { + cache.notify_toc(CacheNotification::TocChanged); } } @@ -1736,6 +1848,7 @@ mod tests { use std::{ collections::{HashMap, HashSet}, fs, + num::NonZeroUsize, path::{Path, PathBuf}, sync::{ Arc, Mutex, @@ -1754,8 +1867,9 @@ mod tests { use super::{ Cache, CacheNotification, ConnectionRegistration, Consumer, ConsumerPathState, FileFacts, - FileStatus, FragmentFact, IdResolution, IdState, LRU_ENTRIES_PER_CONNECTION, TargetFact, - Walker, WatcherPathState, cache_map_capacity, watcher_path_states, + FileStatus, FragmentFact, IdResolution, IdState, LRU_ENTRIES_PER_CONNECTION, ProjectCaches, + TargetFact, Walker, WatcherPathState, cache_map_capacity, get_or_insert_cache, + set_cache_map_capacity, watcher_path_states, }; use crate::webserver::canonicalize; @@ -1859,7 +1973,7 @@ mod tests { ) { let (notify_tx, notify_rx) = mpsc::unbounded_channel(); let registration = ConnectionRegistration::new( - cache.clone(), + cache, connection_id.to_string(), current_file.to_path_buf(), notify_tx, @@ -2419,6 +2533,14 @@ mod tests { .unwrap() .commit_file(&a, None, facts_target("foo", "Bar!")); assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Disconnected)); + + // A registration doesn't keep its cache alive, and outlives it + // harmlessly. + let (registration, _notify_rx) = register(&cache, "conn", &b); + let weak_cache = Arc::downgrade(&cache); + drop(cache); + assert!(weak_cache.upgrade().is_none()); + drop(registration); } // Verify that outdating the TOC notifies every registered connection, since @@ -2538,6 +2660,54 @@ mod tests { ); } + // Verify that the `CacheMap` evicts its least recently used caches, except + // for those in which a connection is registered. + #[test] + fn test_cache_map_eviction() { + let a = PathBuf::from("a"); + let b = PathBuf::from("b"); + let c = PathBuf::from("c"); + let d = PathBuf::from("d"); + let mut caches = ProjectCaches::new(NonZeroUsize::new(3).unwrap()); + let insert = |caches: &mut ProjectCaches, root: &PathBuf| { + get_or_insert_cache(caches, root, || { + Arc::new(Mutex::new(Cache::new(root.clone()))) + }) + }; + let roots = |caches: &ProjectCaches| { + let mut roots: Vec = caches.iter().map(|(root, _)| root.clone()).collect(); + roots.sort(); + roots + }; + let cache_a = insert(&mut caches, &a); + insert(&mut caches, &b); + insert(&mut caches, &c); + // Getting an existing cache returns it, rather than a new one. + assert!(Arc::ptr_eq(&insert(&mut caches, &a), &cache_a)); + + // A connection in the least recently used cache keeps it from being + // evicted, so the next least recently used is evicted instead. + let (_registration, _notify_rx) = register(&cache_a, "conn", &a.join("x.md")); + insert(&mut caches, &b); + insert(&mut caches, &c); + insert(&mut caches, &d); + assert_eq!(roots(&caches), vec![a.clone(), c.clone(), d.clone()]); + + // Shrinking the map likewise keeps the cache with a connection. + set_cache_map_capacity(&mut caches, NonZeroUsize::MIN); + assert_eq!(roots(&caches), vec![a.clone()]); + assert_eq!(caches.cap(), NonZeroUsize::MIN); + + // When only caches with connections remain, the map grows rather than + // evict one. + let cache_d = insert(&mut caches, &d); + let (_registration, _notify_rx) = register(&cache_d, "conn2", &d.join("x.md")); + set_cache_map_capacity(&mut caches, NonZeroUsize::MIN); + assert_eq!(roots(&caches), vec![a.clone(), d.clone()]); + insert(&mut caches, &b); + assert_eq!(roots(&caches), vec![a, b, d]); + } + // ### Watcher tests // // Verify that watcher events become the paths the walker receives: renames @@ -2807,14 +2977,19 @@ mod tests { drain(&consumer_rx), vec![ConsumerPathState::Outdated(b.clone())] ); - // Scanning the outdated file brings it up to date; a second request, - // as when several commits outdate one file, is skipped. To observe - // this, discard its cached metadata, which a rescan would restore. + // Scanning the outdated file brings it up to date. A second request, + // as when several commits outdate one file, is scanned too. To observe + // this, discard its cached metadata, which a rescan restores. consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::UpToDate); cache.lock().unwrap().files.get_mut(&b).unwrap().metadata = None; consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); - assert!(cache.lock().unwrap().files[&b].metadata.is_none()); + assert!(cache.lock().unwrap().files[&b].metadata.is_some()); + // A request for a file the cache has since forgotten is moot. + let forgotten = root.join("forgotten.md"); + fs::write(&forgotten, "# Forgotten\n").unwrap(); + consumer.apply(&cache, ConsumerPathState::Outdated(forgotten.clone())); + assert!(!cache.lock().unwrap().files.contains_key(&forgotten)); // A connection's current file isn't scanned... let (registration, mut notify_rx) = register(&cache, "conn", &a); @@ -2892,6 +3067,73 @@ mod tests { temp_dir.close().unwrap(); } + // Verify that the consumer tells every connection to re-load the TOC when + // its text changes on disk -- even when the TOC is a connection's current + // file, which the consumer leaves to that connection -- and when it's + // deleted. + #[test] + fn test_consumer_toc_changed() { + let (temp_dir, test_dir) = prep_test_dir!(); + let root = canonicalize(&test_dir).unwrap(); + let toc = root.join("toc.md"); + let a = root.join("a.md"); + fs::write(&toc, "# Contents\n").unwrap(); + let cache = Arc::new(Mutex::new(Cache::new(root.clone()))); + let mut consumer = Consumer::default(); + let (_registration, mut notify_rx) = register(&cache, "conn", &a); + let is_target = |cache: &Arc>, id: &str| { + matches!( + cache.lock().unwrap().resolve_id(id), + IdResolution::Target { .. } + ) + }; + + // No sidebar can show a TOC the cache has no record of, since loading + // the TOC in a sidebar records it, so its first scan is not a change. + consumer.apply(&cache, found(&toc)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + + // A changed TOC is scanned, then reported; an unchanged one isn't. + fs::write(&toc, "

Contents

\n").unwrap(); + consumer.apply(&cache, found(&toc)); + assert!(is_target(&cache, "contents")); + assert_eq!(notify_rx.try_recv(), Ok(CacheNotification::TocChanged)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + consumer.apply(&cache, found(&toc)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + + // When the TOC is a connection's current file, the change isn't + // scanned, but is still reported to every connection. + let (_toc_registration, mut toc_notify_rx) = register(&cache, "toc_conn", &toc); + fs::write(&toc, "

Contents

\n").unwrap(); + consumer.apply(&cache, found(&toc)); + assert!(is_target(&cache, "contents")); + for notify_rx in [&mut notify_rx, &mut toc_notify_rx] { + assert_eq!(notify_rx.try_recv(), Ok(CacheNotification::TocChanged)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + } + + // Deleting the TOC is reported as a change, too. The connection whose + // current file it is keeps the TOC in the cache by re-translating it. + fs::remove_file(&toc).unwrap(); + consumer.apply( + &cache, + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(toc.clone())), + ); + assert!(cache.lock().unwrap().files.contains_key(&toc)); + assert_eq!(notify_rx.try_recv(), Ok(CacheNotification::TocChanged)); + assert_eq!(notify_rx.try_recv(), Err(TryRecvError::Empty)); + assert_eq!( + toc_notify_rx.try_recv(), + Ok(CacheNotification::CurrentFileOutdated) + ); + assert_eq!(toc_notify_rx.try_recv(), Ok(CacheNotification::TocChanged)); + assert_eq!(toc_notify_rx.try_recv(), Err(TryRecvError::Empty)); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + // Verify that the consumer forgets deleted files and directories, and the // cached files a walk doesn't find. #[test] diff --git a/server/src/translation.rs b/server/src/translation.rs index 7826c301..7de7faec 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -248,7 +248,10 @@ use crate::{ CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlock, CodeMirrorDocBlockVec, SourceFileMetadata, TranslationResultsString, UNICODE_CURSOR_MARKER, byte_index_of, - cache::{CacheMap, CacheNotification, ConnectionRegistration, cache_map_capacity}, + cache::{ + CacheMap, CacheNotification, ConnectionRegistration, cache_map_capacity, + set_cache_map_capacity, + }, codechat_for_web_to_source, diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, find_path_to_toc, minify, project_cache, remove_tinymce_data, source_to_codechat_for_web_string, transform_html, @@ -418,6 +421,16 @@ struct SidebarToc { html: String, } +/// The purpose of a `LoadFile` request sent to the IDE, which determines how +/// its response is handled. +enum LoadFileRequest { + /// Serve the file to the Client, which requested it over HTTP. + Http(ProcessingTaskHttpRequest), + /// Re-load the TOC at this path, which the sidebar shows, after the cache + /// reported that the TOC changed; see `reload_sidebar_toc`. + SidebarToc(PathBuf), +} + /// This holds the state used by the main loop of the translation task; this /// allows factoring out lengthy contents in the loop into subfunctions. struct TranslationTask { @@ -448,7 +461,7 @@ struct TranslationTask { /// until the sidebar loads it, or if the page has no sidebar. sidebar_toc: Option, /// A map of `LoadFile` requests sent to the IDE, awaiting its response. - load_file_requests: HashMap, + load_file_requests: HashMap, /// The id for messages created by the server. Leave space for a server /// message during the init phase. id: f64, @@ -499,11 +512,10 @@ fn resize_cache_map( // Hold the list's lock while resizing, so that connections which start or // stop at the same time can't apply their counts out of order. let processing_task_queue_tx = app_state.processing_task_queue_tx.lock().unwrap(); - app_state - .cache - .lock() - .unwrap() - .resize(cache_map_capacity(processing_task_queue_tx.len())); + set_cache_map_capacity( + &mut app_state.cache.lock().unwrap(), + cache_map_capacity(processing_task_queue_tx.len()), + ); } /// This is the processing task for the Visual Studio Code IDE. It handles all @@ -652,7 +664,7 @@ pub async fn translation_task( })); // Store the ID and request, which are needed to send a // response when the `LoadFile` result is received. - tt.load_file_requests.insert(tt.id.to_bits(), http_request); + tt.load_file_requests.insert(tt.id.to_bits(), LoadFileRequest::Http(http_request)); tt.id += MESSAGE_ID_INCREMENT; } @@ -950,10 +962,11 @@ impl TranslationTask { queue_send_func!(self.to_client_tx.send(ide_message)); return true; } - // Ensure there's an HTTP request for this `LoadFile` result. - let Some(http_request) = self.load_file_requests.remove(&ide_message.id.to_bits()) else { + // Ensure there's a request for this `LoadFile` result. + let Some(load_file_request) = self.load_file_requests.remove(&ide_message.id.to_bits()) + else { error!( - "Error: no HTTP request found for LoadFile result ID {}.", + "Error: no request found for LoadFile result ID {}.", ide_message.id ); return true; @@ -976,6 +989,17 @@ impl TranslationTask { ResultOkTypes::LoadFile(file_contents) => file_contents, }, }; + let http_request = match load_file_request { + LoadFileRequest::Http(http_request) => http_request, + LoadFileRequest::SidebarToc(toc_path) => { + return self + .reload_sidebar_toc( + toc_path, + file_contents_option.map(|(file_contents, _version)| file_contents), + ) + .await; + } + }; // Process the file contents. Since VSCode doesn't have a PDF viewer, // determine if this is a PDF file. (TODO: look at the magic number also @@ -1642,7 +1666,7 @@ impl TranslationTask { self.cache_registration = None; self.cache_registration = find_path_to_toc(&self.current_file).map(|path_to_toc| { ConnectionRegistration::new( - project_cache(&self.cache, &self.current_file, &path_to_toc), + &project_cache(&self.cache, &self.current_file, &path_to_toc), self.connection_id.clone(), self.current_file.clone(), self.cache_notify_tx.clone(), @@ -1662,16 +1686,23 @@ impl TranslationTask { ) -> bool { let mut is_current_file_outdated = false; let mut is_toc_outdated = false; + let mut is_toc_changed = false; let queued = iter::from_fn(|| self.cache_notify_rx.try_recv().ok()); for notification in iter::once(notification).chain(queued) { match notification { CacheNotification::CurrentFileOutdated => is_current_file_outdated = true, CacheNotification::TocOutdated => is_toc_outdated = true, + CacheNotification::TocChanged => is_toc_changed = true, } } if is_current_file_outdated && !self.re_translate_current_file().await { return false; } + // Re-loading the TOC re-renders it, which also renders anew a TOC whose + // rendering is outdated. + if is_toc_changed { + return self.request_sidebar_toc().await; + } if is_toc_outdated && !self.refresh_sidebar_toc().await { return false; } @@ -1771,6 +1802,72 @@ impl TranslationTask { true } + /// Ask the IDE for the text of the TOC which the Client's sidebar shows, + /// after the project's cache reports that this text changed on disk. The + /// IDE's text, if it has the TOC open, supersedes the text on disk, as when + /// the sidebar first loaded the TOC. `reload_sidebar_toc` handles the IDE's + /// response. + async fn request_sidebar_toc( + &mut self, + // False if the IDE's queue is closed. + ) -> bool { + // Until the sidebar loads the TOC, there's nothing to re-load; loading + // it reads the TOC's current text. + let Some(sidebar_toc) = &self.sidebar_toc else { + return true; + }; + let toc_path = sidebar_toc.path.clone(); + debug!( + "Sending LoadFile id = {} to reload the sidebar's TOC.", + self.id + ); + queue_send_func!(self.to_ide_tx.send(EditorMessage { + id: self.id, + // As when the sidebar loaded it, the TOC isn't loaded as the file to + // edit, so it isn't assigned a version. + message: EditorMessageContents::LoadFile(toc_path.clone(), false), + })); + self.load_file_requests + .insert(self.id.to_bits(), LoadFileRequest::SidebarToc(toc_path)); + self.id += MESSAGE_ID_INCREMENT; + true + } + + /// Re-render the TOC which the Client's sidebar shows from its re-loaded + /// text, then send the Client the result if it changed. + async fn reload_sidebar_toc( + &mut self, + // The TOC re-loaded. + toc_path: PathBuf, + // The TOC's text from the IDE; `None` if the IDE doesn't have the TOC + // open, in which case its text is read from disk. + ide_text: Option, + // False if the Client's queue is closed. + ) -> bool { + let text = match ide_text { + Some(text) => Some(text), + None => match File::open(&toc_path).await { + Ok(mut file) => try_read_as_text(&mut file).await, + Err(_) => None, + }, + }; + // While the IDE responded, the Client may have left the page whose + // sidebar showed this TOC. + let Some(sidebar_toc) = self + .sidebar_toc + .as_mut() + .filter(|sidebar_toc| sidebar_toc.path == toc_path) + else { + return true; + }; + // A TOC which can't be read -- since it was deleted, say -- keeps its + // last text, but may still need rendering anew. + if let Some(text) = text { + sidebar_toc.source = text; + } + self.refresh_sidebar_toc().await + } + /// After an edit to the current file, refresh the Client's sidebar if that /// file is the TOC it shows. The edit doesn't outdate the TOC in the cache: /// a file's translation never outdates the file itself. diff --git a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md index 39cbddf4..5a887ba9 100644 --- a/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md +++ b/server/tests/fixtures/code_chat_editor/ide/vscode/tests/test_vscode_ide_websocket10/toc.md @@ -1 +1 @@ -

Foo

+

Contents

From aa820e495307f7e4489a0fbe512d5e0adf0c6391 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 9 Oct 2026 12:28:35 -0500 Subject: [PATCH 11/14] Fix: use more accurate file stamps instead of length/size. Co-authored by Claude. --- server/src/processing.rs | 24 +-- server/src/processing/cache-spec.md | 85 +++++--- server/src/processing/cache.rs | 311 ++++++++++++++++++++-------- 3 files changed, 291 insertions(+), 129 deletions(-) diff --git a/server/src/processing.rs b/server/src/processing.rs index fed26e03..a8415b20 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -79,7 +79,7 @@ use crate::{ CodeDocBlock, DocBlock, LEXERS, LanguageLexerCompiled, source_lexer, supported_languages::MARKDOWN_MODE, }, - processing::cache::{FileFacts, FragmentFact, IdResolution, TargetFact}, + processing::cache::{FileFacts, FileStamp, FragmentFact, IdResolution, TargetFact}, webserver::canonicalize, }; use cache::{Cache, CacheMap, get_or_insert_cache}; @@ -1493,16 +1493,16 @@ fn hydrate_dom( // The parsed, patched DOM plus the walk results needed by later phases. ) -> io::Result<(Rc, WalkContext)> { let dom = html_to_dom(html, None)?; - // Read the file's metadata and canonicalize its path before taking the - // cache lock, so that no I/O happens while the lock is held. The metadata - // captures the state of the file whose content is being processed (the - // cache's consumer, which read that content from the file itself, replaces - // this with the metadata it read beforehand; see `scan_file` in - // `cache.rs`); the canonicalization satisfies the cache's requirement that - // all paths are canonicalized and absolute. (Canonicalization fails for - // files which don't exist on disk -- such as tests -- in which case the - // path is used as-is.) - let metadata = file.metadata().ok(); + // Stamp the file and canonicalize its path before taking the cache lock, so + // that no I/O happens while the lock is held. The stamp captures the state + // of the file whose content is being processed (the cache's consumer, + // which read that content from the file itself, replaces this with the + // stamp it took beforehand; see `scan_file` in `cache.rs`); the + // canonicalization satisfies the cache's requirement that all paths are + // canonicalized and absolute. (Canonicalization fails for files which + // don't exist on disk -- such as tests -- in which case the path is used + // as-is.) + let stamp = file.metadata().ok().as_ref().map(FileStamp::new); let path = canonicalize(file).unwrap_or_else(|_| file.to_path_buf()); // ### Assign requested ids // @@ -1545,7 +1545,7 @@ fn hydrate_dom( let mut cache_guard = cache.lock().unwrap(); cache_guard.commit_file( &walk_context.path, - metadata, + stamp, mem::take(&mut walk_context.facts), ); // ### Patch cross-references diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 57fe9f5d..70ef8199 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -2,11 +2,11 @@ Cache spec ========== The cache stores the location (file name and ID) and contents (inner HTML or -code/doc blocks for fragments) of a target. Targets are HTML elements (excluding -``s) with an ID. +code/doc blocks for fragments) of a target. Targets are HTML elements with an +ID. The goal of the cache is to support cross-references and gather elements, and to -ensure that all IDs are unique within a project. This means that +assist in ensuring that all IDs are unique within a project. This means that cross-references and gather elements persist across moving or renaming files, since the IDs will be found in the cache. @@ -36,27 +36,32 @@ is only present in the Client. Details: -* This element does not allow an `id` attribute. +* This element does not allow any attribute except for `ref`; specifically, + an `id` attribute in not allowed. * The inner HTML is always taken before cache hydration, to prevent circular dependencies: ```html -

See

-

See

+

See

+

See

``` Including cache hydration would cause the inner HTML to be updated each time the file is processed, outdating the other file. -* If the `id` referred to isn't found or refers to a duplicate id, the inner - text is instead an appropriate error message. +* If the `ref` attribute is missing, the `id` referred to isn't found, refers to + a duplicate id, or is an invalid CSS identifier, the inner text is instead an + appropriate error message. * If the cross reference is to a gather element, the text is the gather element's inner HTML, not the gathered code/doc blocks. +* Cross references to fragments are not allowed; the fragment contents produced + is too verbose. An error message is produced in this case. + Fragments and gather elements ----------------------------- @@ -133,11 +138,11 @@ things`: ``` -Search ------- +Search (future work) +-------------------- -The cache supports searching the (cleaned) inner HTML of all `Target`s and -gather elements; search does not include `Fragment` contents. +The cache will support searching the (cleaned) inner HTML of all `Target`s and +gather elements; search will not include `Fragment` contents. Auto-assignment of ids ---------------------- @@ -156,8 +161,8 @@ The autogenerated id must be a valid Autogenerated ids must be checked to make sure they don't collide with an existing id. -Design ------- +Cache design +------------ The cache is a single plain-data structure, shared as an `Arc>`; all consistency comes from that one lock, so no per-item locking (and therefore @@ -203,13 +208,14 @@ Updating the cache is a multi-phase process: class and insert its list of gathered fragment contents as its next sibling. Item 2 requires the cache to track dependents of an id, so that files containing -these dependencies can be Outdated; these are sent to the `Cache::consumer_tx` -channel for processing. Dependencies are tracked at file granularity, on the id -rather than on the item defining it: `IdEntry::dependents` is the set of files -whose rendered output depends on this id, by any means -- an `` -or a `data-gather` list naming it. One uniform set suffices in all three -`IdState`s because the typical action taken on a dependent is marking this file -outdated (fragments also use their dependents to generate "See x" links). +these dependencies can be marked `Outdated`; these are sent to the +`Cache::consumer_tx` channel for processing. Dependencies are tracked at file +granularity, on the id rather than on the item defining it: +`IdEntry::dependents` is the set of files whose rendered output depends on this +id, by any means -- an `` or a `data-gather` list naming it. One +uniform set suffices in all three `IdState`s because the typical action taken on +a dependent is marking this file outdated (fragments also use their dependents +to generate "See x" links). Item 2 also requires the cache to define what constitutes a difference which would trigger outdating dependencies. The state which is checked for differences @@ -284,13 +290,12 @@ The `AppState` structure keeps an LRU map of project path -> cache (note that the cache includes a watcher and walker). Adding/removing connections resizes the LRU size to (connections \* `LRU_ENTRIES_PER_CONNECTION`) + 1; however, the cache for all current connections may not be dropped during an LRU eviction. -This approach seeks to balance cache memory consumption with the time required -to load the cache for an uncached project. +This LRU-based approach seeks to balance cache memory consumption with the time +required to load the cache for an uncached project. The cache fully supports multiple connections to the same project. It does not walk symlinks, since these could lead to two cache entries for the same file. -There's no explicit support for nested projects. While it's possible for one -project to be nested inside another, this design does not support this case. +There's no explicit support for nested projects. The cache initially registers a watcher with the provided project path and a consumer thread which receives results from the walker, along with a message @@ -357,16 +362,22 @@ A consumer reads paths from the receiver paired to the `Cache::consumer_tx` channel. It holds a weak reference to the cache, and exits when this channel is closed or the weak reference can't be upgraded. It is implemented as a thread. -* If the file exists and differs from its cached metadata's modification time - and length or if the file's status is `Outdated`: +The consumer decides whether a file changed by its stamp, as Git's index does: a +snapshot of the file's length and modification time (plus its change time and +inode on Unix), taken before its contents were read. A stamp taken within a few +seconds of the file's last write never matches, since a later write in the same +timestamp tick could leave this metadata unchanged (Git's "racy clean" problem). - * If the file is the current file for any of the cache's connection, ignore - it. This is handled by the standard IDE to Client translation mechanism. +* If the file exists and doesn't match its cached stamp or if the file's status + is `Outdated`: * If the file is the TOC, send `TocChanged` or `TocOutdated` to all connections. The file will be translated and updated in the cache when that message is processed. + * If the file is the current file for any of the cache's connection, ignore + it. This is handled by the standard IDE to Client translation mechanism. + * If not translatable, the file is still added to the cache, to prevent reprocessing if the directory is re-walked. @@ -385,12 +396,20 @@ closed or the weak reference can't be upgraded. It is implemented as a thread. connections. 2. If the outdated file is the current file for one or more connections, then send `CurrentFileOutdated` to those connections. - 3. If neither 1 or 2 is true, the `Outdated(file)` message is sent to itself + 3. If neither 1 or 2 is true, the `Rescan(file)` message is sent to itself via the consumer TX channel for later processing. * If the path doesn't exist, search the cache for it; if found, remove it. Otherwise, assume the path was a directory. Search the cache for all files in this directory, removing them. +* A `Rescan(file)` asks the consumer to bring a file the cache already has up to + date: one a commit outdated (see step 3 above), or a connection's current file + once no connection shows it, since the consumer ignores current files. Unlike + a path from the walker, a `Rescan` hasn't passed the walker's ignore rules. + It's therefore skipped if the cache has since forgotten the file (a walk may + have found it newly ignored, for example), and it doesn't keep a walk in + progress from forgetting the file. + * If this is a `WalkStarted`, put all files in the cache which this directory contains in a set. Remove files produced by the walk from this set. After `WalkFinished`, remove any files remaining in the set from the cache, since @@ -434,12 +453,12 @@ Overall shutdown sequence: 2. The `Cache` drops the `_watcher` and the `consumer_tx` channel. 3. When dropped, the `_watcher` closes its channel to the walker, stops the watcher, drops its `consumer_tx` channel, and exits. -4. The consumer thread exits when either the its consumer rx channel closes or - the weak pointer fails to upgrade. +4. The consumer thread exits when either its consumer rx channel closes or the + weak pointer fails to upgrade. TODO: -* `SaveOnScan` and search functionality is not implemented yet. This is future +* `SaveOnScan` and search functionality are not implemented yet. This is future work.


diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index a108e199..effcf4d6 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -21,6 +21,8 @@ // ------- // // ### Standard library +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; use std::{ collections::{BTreeMap, HashMap, HashSet}, fs::{self, File, Metadata}, @@ -35,7 +37,7 @@ use std::{ mpsc::{self, Receiver, SendError, Sender}, }, thread, - time::Duration, + time::{Duration, SystemTime}, }; // ### Third-party @@ -110,11 +112,10 @@ pub struct Cache { /// `None`. #[derive(Default)] pub(super) struct FileEntry { - /// The file's metadata when its content was read, which tells the consumer - /// whether this data still represents the file: the consumer reprocesses a - /// file whose modification time or length differs. `None` if the file - /// doesn't exist or the metadata can't be determined. - pub(super) metadata: Option, + /// The file's stamp, taken when its content was read; the consumer + /// reprocesses the file when it no longer matches. `None` if the file + /// doesn't exist or its metadata can't be determined. + pub(super) stamp: Option, /// The status of this file. pub(super) status: FileStatus, /// All targets on this page, keyed by id. @@ -130,6 +131,65 @@ pub(super) struct FileEntry { pub(super) gathers: Vec, } +/// How long after a file's modification time a stamp of it must be taken for +/// that time to be trusted; see `FileStamp::is_racy`. This spans FAT's +/// 2-second timestamp resolution plus some clock skew on network shares. +const RACY_WINDOW: Duration = Duration::from_secs(3); + +/// A snapshot of a file's metadata, taken immediately before its contents are +/// read, which tells the consumer whether the cache still represents the file +/// without reading it again. Git's index stores the same snapshot of each +/// tracked file for the same purpose; see +/// [Use of index and Racy Git problem](https://github.com/git/git/blob/master/Documentation/technical/racy-git.adoc). +pub(super) struct FileStamp { + /// The metadata compared against a later snapshot. + stat: StatData, + /// True if the file was modified within `RACY_WINDOW` of this snapshot. A + /// file's timestamp advances only every few milliseconds -- every 2 + /// seconds, on FAT -- so a write which follows this snapshot within the + /// same tick, without changing the file's length, changes its contents + /// while leaving its metadata unchanged. A racy stamp therefore never + /// matches, forcing the file to be read again; the stamp taken then is + /// trustworthy once the file's last write is old enough. Git calls such + /// entries "racily clean". + is_racy: bool, +} + +/// The parts of a file's metadata which change when its contents do. Unix +/// supplies the fields Git compares. On Windows, the change time and the file +/// index which would stand in for Unix's change time and inode are available +/// only in nightly Rust, leaving just the length and modification time. The +/// creation time can't take their place: NTFS caches a file's creation time +/// for a short period after the file is deleted or renamed away, then gives it +/// to a new file which takes that name -- precisely what an editor does when +/// it saves by writing a temporary file, then renaming it over the original. +/// Windows calls this file system tunneling; see the remarks in +/// [`FileSystemInfo.CreationTime`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesysteminfo.creationtime#remarks) +/// and Raymond Chen's +/// [The apocryphal history of file system tunnelling](https://devblogs.microsoft.com/oldnewthing/20050715-14/?p=34923). +#[derive(PartialEq, Eq)] +struct StatData { + len: u64, + /// The modification time; `None` if the platform doesn't report one. Two + /// `None`s compare equal, which would report every same-length edit as no + /// change, so `FileStamp::new` marks a stamp without a modification time + /// as racy, which keeps it from ever matching. Compare these times only + /// for equality, never order: + /// [`SystemTime`](https://doc.rust-lang.org/std/time/struct.SystemTime.html) + /// isn't monotonic, so a file saved after another may carry an earlier + /// time. + modified: Option, + /// The change time as (seconds, nanoseconds). Unlike the modification + /// time, which `touch -d`, `cp -p`, `rsync -t`, and archive extraction set + /// to arbitrary values, no program can set it. + #[cfg(unix)] + ctime: (i64, i64), + /// The device and inode numbers, which change when an editor saves by + /// renaming a new file over the old one. + #[cfg(unix)] + dev_ino: (u64, u64), +} + /// Everything the cache knows about one id: where (if anywhere) it's defined, /// and which files must be rebuilt when that changes. #[derive(Default)] @@ -335,14 +395,13 @@ pub(super) enum ConsumerPathState { WalkStarted(PathBuf), /// The walk announced by the preceding `WalkStarted` is complete. WalkFinished, - /// A file which a connection stopped showing; see - /// `ConnectionRegistration::drop`. A walk in progress didn't find this - /// file, so unlike the files it does find, this can't keep the walk from - /// forgetting it. - Released(PathBuf), - /// A commit outdated the rendered output of this file, whose content is - /// unchanged; see `Cache::mark_outdated`. - Outdated(PathBuf), + /// A request to bring a file the cache already has up to date: a commit + /// outdated its rendered output (see `Cache::mark_outdated`), or a + /// connection stopped showing it (see `ConnectionRegistration::drop`). + /// Unlike a path from the walker, this is no evidence that the file isn't + /// ignored, so it can't keep a walk in progress from forgetting the file, + /// and is moot once the cache has forgotten it. + Rescan(PathBuf), } // ### Connections @@ -571,10 +630,10 @@ impl Cache { &mut self, // The file whose facts these are. path: &Path, - // The file's metadata, captured when its content was read; `None` if - // unavailable. Read this *before* locking the cache, so no I/O happens - // while the lock is held. - metadata: Option, + // The file's stamp, taken when its content was read; `None` if its + // metadata is unavailable. Take this *before* locking the cache, so no + // I/O happens while the lock is held. + stamp: Option, // The facts collected from the file's DOM. facts: FileFacts, // The outcome: outdated files. Reprocessing these outdated files may @@ -752,7 +811,7 @@ impl Cache { // file is written and re-read (see `Auto-assignment of ids`), at which // point they arrive here like any other id. let entry = self.files.get_mut(path).expect("entry was created above"); - entry.metadata = metadata; + entry.stamp = stamp; entry.targets = new_targets; entry.fragments = new_fragments; entry.gathers = new_gathers; @@ -898,7 +957,7 @@ impl Cache { for path in outdated.iter().filter(|path| !self.is_shown(path)) { // A send fails only once the consumer has exited, when this // cache is being dropped and so needs no more updates. - let _ = consumer_tx.send(ConsumerPathState::Outdated(path.clone())); + let _ = consumer_tx.send(ConsumerPathState::Rescan(path.clone())); } } } @@ -1117,6 +1176,44 @@ impl IdState { } } +impl FileStamp { + pub(super) fn new( + // The file's metadata, read immediately before its contents. + metadata: &Metadata, + ) -> Self { + let modified = metadata.modified().ok(); + // A modification time in the future -- from clock skew between this + // machine and a network share, for example -- is racy as well. + let is_racy = modified.is_none_or(|modified| { + !SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age >= RACY_WINDOW) + }); + FileStamp { + stat: StatData { + len: metadata.len(), + modified, + #[cfg(unix)] + ctime: (metadata.ctime(), metadata.ctime_nsec()), + #[cfg(unix)] + dev_ino: (metadata.dev(), metadata.ino()), + }, + is_racy, + } + } + + /// Report whether the file's contents are unchanged since this stamp was + /// taken. + fn is_unmodified( + &self, + // A stamp taken from the file now. + current: &FileStamp, + // True if the contents are unchanged; false if they may have changed. + ) -> bool { + !self.is_racy && self.stat == current.stat + } +} + impl ConnectionRegistration { /// List a connection in a project's cache. Drop any earlier registration of /// this connection first: dropping it afterward would remove this listing, @@ -1169,7 +1266,7 @@ impl Drop for ConnectionRegistration { if !cache.is_current_file(&connection.current_file) && let Some(consumer_tx) = &cache.consumer_tx { - let _ = consumer_tx.send(ConsumerPathState::Released(connection.current_file)); + let _ = consumer_tx.send(ConsumerPathState::Rescan(connection.current_file)); } } } @@ -1621,7 +1718,7 @@ impl Consumer { if let Some(unwalked) = &mut self.unwalked { unwalked.remove(&path); } - scan_file(cache, &path, false); + scan_file(cache, &path, true); } // Per the spec, only a path which no longer exists is forgotten. // One which exists again by the time this is applied -- recreated @@ -1629,12 +1726,11 @@ impl Consumer { // by renaming a new file over the old one -- is scanned instead. ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(path)) => { match fs::symlink_metadata(&path) { - Ok(metadata) if !metadata.is_symlink() => scan_file(cache, &path, false), + Ok(metadata) if !metadata.is_symlink() => scan_file(cache, &path, true), _ => forget_path(cache, &path), } } - ConsumerPathState::Released(path) => scan_file(cache, &path, false), - ConsumerPathState::Outdated(path) => scan_file(cache, &path, true), + ConsumerPathState::Rescan(path) => scan_file(cache, &path, false), } } } @@ -1683,16 +1779,16 @@ fn scan_file( cache: &Arc>, // The file to scan. path: &Path, - // True if a commit outdated the file, whose content is unchanged; it's - // always scanned, unless the cache has since forgotten it. False if the - // file itself may have changed; it's scanned unless its cached metadata's - // modification time and length match the file's. - is_outdated: bool, + // True if the walker sent this file, which therefore passed its ignore + // rules; only such a file is scanned when the cache doesn't have it. A + // rescan of a file the cache has since forgotten -- one a walk found + // ignored, say -- is moot. + is_from_walker: bool, ) { // Perform I/O before locking the cache, so that the lock isn't held during // it. - let metadata = match fs::metadata(path) { - Ok(metadata) if metadata.is_file() => metadata, + let stamp = match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => FileStamp::new(&metadata), // The file no longer exists -- or a directory has replaced it, which // the walker reports separately. _ => { @@ -1708,8 +1804,8 @@ fn scan_file( let cache = cache.lock().unwrap(); let entry = cache.files.get(path); let is_modified = entry - .and_then(|entry| entry.metadata.as_ref()) - .is_none_or(|cached| !is_unmodified(cached, &metadata)); + .and_then(|entry| entry.stamp.as_ref()) + .is_none_or(|cached| !cached.is_unmodified(&stamp)); is_toc_changed = is_modified && entry.is_some() && path == cache.toc_path(); if cache.is_current_file(path) { // The connections' sidebars show the TOC from their own IDE's text @@ -1719,12 +1815,13 @@ fn scan_file( } return; } - let is_up_to_date = if is_outdated { - entry.is_none() - } else { - !is_modified && entry.is_some_and(|entry| entry.status == FileStatus::UpToDate) + // A cached file needs scanning if it changed, or if a commit outdated + // it, which its status records. + let is_skipped = match entry { + None => !is_from_walker, + Some(entry) => !is_modified && entry.status == FileStatus::UpToDate, }; - if is_up_to_date { + if is_skipped { return; } } @@ -1738,7 +1835,7 @@ fn scan_file( // // TODO: once the Server has user-selected options, implement the spec's // `SaveOnScan` option here: translate the result back to source, then - // write any changes to the file and update its cached metadata. + // write any changes to the file and update its cached stamp. let outcome = match contents.map(|contents| { source_to_codechat_for_web( &contents.replace("\r\n", "\n"), @@ -1767,26 +1864,26 @@ fn scan_file( cache.notify_current_file_outdated(&HashSet::from([path.to_path_buf()])); } else { match outcome { - // The translation's commit recorded the file's metadata as of the + // The translation's commit recorded a stamp taken as of the // commit, which follows the reading of its text. A write in // between would leave the cache describing the old text with the - // new metadata, so the consumer would skip the watcher's report of - // that write. Record the metadata read before the text instead, - // which any such write changes. + // new stamp, so the consumer would skip the watcher's report of + // that write. Record the stamp taken before the text was read + // instead, which no such write leaves the file matching. ScanOutcome::Translated => { if let Some(entry) = cache.files.get_mut(path) { - entry.metadata = Some(metadata); + entry.stamp = Some(stamp); } } // A file which can't be translated defines nothing. Committing it // as such withdraws whatever it defined before it stopped being - // translatable, and records its metadata, so that later walks skip - // it until it changes rather than reading it again. + // translatable, and records its stamp, so that later walks skip it + // until it changes rather than reading it again. ScanOutcome::Untranslatable => { - cache.commit_file(path, Some(metadata), FileFacts::default()); + cache.commit_file(path, Some(stamp), FileFacts::default()); } - // Leave the cache as it is: without metadata matching the file, - // the next walk tries again. + // Leave the cache as it is: without a stamp matching the file, the + // next walk tries again. ScanOutcome::Failed => {} } } @@ -1822,19 +1919,6 @@ fn read_text( Ok(String::from_utf8(bytes).ok()) } -/// Report whether two snapshots of a file's metadata agree on its modification -/// time and length, which the consumer takes to mean that the file's contents -/// are unchanged. -fn is_unmodified( - // The metadata cached when the file was last processed. - cached: &Metadata, - // The file's metadata now. - current: &Metadata, - // True if the modification times and lengths match. -) -> bool { - cached.len() == current.len() && cached.modified().ok() == current.modified().ok() -} - impl Default for Cache { fn default() -> Self { Cache::new(PathBuf::new()) @@ -1855,7 +1939,7 @@ mod tests { mpsc::{self as std_mpsc, Receiver}, }, thread::sleep, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; use notify_debouncer_full::notify::{ @@ -1867,9 +1951,9 @@ mod tests { use super::{ Cache, CacheNotification, ConnectionRegistration, Consumer, ConsumerPathState, FileFacts, - FileStatus, FragmentFact, IdResolution, IdState, LRU_ENTRIES_PER_CONNECTION, ProjectCaches, - TargetFact, Walker, WatcherPathState, cache_map_capacity, get_or_insert_cache, - set_cache_map_capacity, watcher_path_states, + FileStamp, FileStatus, FragmentFact, IdResolution, IdState, LRU_ENTRIES_PER_CONNECTION, + ProjectCaches, RACY_WINDOW, TargetFact, Walker, WatcherPathState, cache_map_capacity, + get_or_insert_cache, set_cache_map_capacity, watcher_path_states, }; use crate::webserver::canonicalize; @@ -1981,6 +2065,21 @@ mod tests { (registration, notify_rx) } + fn set_modified(path: &Path, modified: SystemTime) { + fs::File::options() + .write(true) + .open(path) + .unwrap() + .set_modified(modified) + .unwrap(); + } + + // Move a file's modification time back past `RACY_WINDOW`, as for a file + // which wasn't just written, so that a stamp taken of it can match. + fn backdate(path: &Path) { + set_modified(path, SystemTime::now() - 2 * RACY_WINDOW); + } + // ### Cross-reference tests // // Verify that a cross-reference to an existing target records the @@ -2624,9 +2723,9 @@ mod tests { assert_eq!( sent, vec![ - ConsumerPathState::Outdated(b.clone()), - ConsumerPathState::Outdated(c.clone()), - ConsumerPathState::Outdated(toc.clone()), + ConsumerPathState::Rescan(b.clone()), + ConsumerPathState::Rescan(c.clone()), + ConsumerPathState::Rescan(toc.clone()), ] ); @@ -2637,7 +2736,7 @@ mod tests { .lock() .unwrap() .commit_file(&a, None, facts_target("foo", "Bar")); - assert_eq!(drain(&consumer_rx), vec![ConsumerPathState::Outdated(b)]); + assert_eq!(drain(&consumer_rx), vec![ConsumerPathState::Rescan(b)]); let mut notifications = vec![notify_rx.try_recv().unwrap(), notify_rx.try_recv().unwrap()]; notifications.sort_by_key(|notification| format!("{notification:?}")); assert_eq!( @@ -2923,6 +3022,40 @@ mod tests { temp_dir.close().unwrap(); } + // ### File stamp tests + // + // Verify that a stamp taken soon after its file was written never matches, + // while a stamp of an older file matches until the file changes. + #[test] + fn test_file_stamp() { + let (temp_dir, test_dir) = prep_test_dir!(); + let path = test_dir.join("a.md"); + let stamp = |path: &Path| FileStamp::new(&fs::metadata(path).unwrap()); + + // Simulate a second write in the same timestamp tick as the first, + // which leaves the length and modification time unchanged. + fs::write(&path, "one").unwrap(); + let racy = stamp(&path); + let modified = fs::metadata(&path).unwrap().modified().unwrap(); + fs::write(&path, "two").unwrap(); + set_modified(&path, modified); + let current = stamp(&path); + // Only the racy rule tells these apart on Windows; on Unix, the write + // also changes the change time. + #[cfg(not(unix))] + assert!(racy.stat == current.stat); + assert!(!racy.is_unmodified(¤t)); + + backdate(&path); + let trusted = stamp(&path); + assert!(trusted.is_unmodified(&stamp(&path))); + fs::write(&path, "three").unwrap(); + assert!(!trusted.is_unmodified(&stamp(&path))); + + // Report any errors produced when removing the temporary directory. + temp_dir.close().unwrap(); + } + // ### Consumer tests // // Verify that the consumer scans the files the walker sends into the cache, @@ -2936,6 +3069,8 @@ mod tests { let b = root.join("b.md"); fs::write(&a, "

Foo

\n").unwrap(); fs::write(&b, "See .\n").unwrap(); + backdate(&a); + backdate(&b); let cache = Arc::new(Mutex::new(Cache::new(root.clone()))); let (consumer_tx, consumer_rx) = std_mpsc::channel(); cache.lock().unwrap().consumer_tx = Some(consumer_tx); @@ -2975,20 +3110,27 @@ mod tests { assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::Outdated); assert_eq!( drain(&consumer_rx), - vec![ConsumerPathState::Outdated(b.clone())] + vec![ConsumerPathState::Rescan(b.clone())] ); - // Scanning the outdated file brings it up to date. A second request, - // as when several commits outdate one file, is scanned too. To observe - // this, discard its cached metadata, which a rescan restores. - consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); + // Scanning the outdated file brings it up to date, so a second request, + // as when several commits outdate one file, isn't scanned. To observe + // this, discard its cached cross-references, which a rescan restores. + consumer.apply(&cache, ConsumerPathState::Rescan(b.clone())); assert_eq!(*status(&cache.lock().unwrap(), &b), FileStatus::UpToDate); - cache.lock().unwrap().files.get_mut(&b).unwrap().metadata = None; - consumer.apply(&cache, ConsumerPathState::Outdated(b.clone())); - assert!(cache.lock().unwrap().files[&b].metadata.is_some()); + cache + .lock() + .unwrap() + .files + .get_mut(&b) + .unwrap() + .xrefs + .clear(); + consumer.apply(&cache, ConsumerPathState::Rescan(b.clone())); + assert!(cache.lock().unwrap().files[&b].xrefs.is_empty()); // A request for a file the cache has since forgotten is moot. let forgotten = root.join("forgotten.md"); fs::write(&forgotten, "# Forgotten\n").unwrap(); - consumer.apply(&cache, ConsumerPathState::Outdated(forgotten.clone())); + consumer.apply(&cache, ConsumerPathState::Rescan(forgotten.clone())); assert!(!cache.lock().unwrap().files.contains_key(&forgotten)); // A connection's current file isn't scanned... @@ -3014,13 +3156,13 @@ mod tests { drop(registration); assert_eq!( drain(&consumer_rx), - vec![ConsumerPathState::Released(a.clone())] + vec![ConsumerPathState::Rescan(a.clone())] ); - consumer.apply(&cache, ConsumerPathState::Released(a.clone())); + consumer.apply(&cache, ConsumerPathState::Rescan(a.clone())); assert!(!cache.lock().unwrap().files.contains_key(&a)); assert_eq!( drain(&consumer_rx), - vec![ConsumerPathState::Outdated(b.clone())] + vec![ConsumerPathState::Rescan(b.clone())] ); // A file which isn't text, or which no lexer recognizes, defines @@ -3095,6 +3237,7 @@ mod tests { // A changed TOC is scanned, then reported; an unchanged one isn't. fs::write(&toc, "

Contents

\n").unwrap(); + backdate(&toc); consumer.apply(&cache, found(&toc)); assert!(is_target(&cache, "contents")); assert_eq!(notify_rx.try_recv(), Ok(CacheNotification::TocChanged)); @@ -3182,7 +3325,7 @@ mod tests { // walk skipped the file as ignored. consumer.apply(&cache, ConsumerPathState::WalkStarted(sub.clone())); consumer.apply(&cache, found(&b)); - consumer.apply(&cache, ConsumerPathState::Released(d.clone())); + consumer.apply(&cache, ConsumerPathState::Rescan(d.clone())); consumer.apply(&cache, ConsumerPathState::WalkFinished); assert_eq!(cached(&cache), vec![a.clone(), b.clone()]); From 4994fa6cb4fb033d67a0259696666002f63dc6d4 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 9 Oct 2026 16:04:12 -0500 Subject: [PATCH 12/14] Update: cache spec. --- server/src/processing.rs | 113 ++++++++-------- server/src/processing/cache-spec.md | 123 ++++++++--------- server/src/processing/cache.rs | 197 +++++++++++++++------------- 3 files changed, 219 insertions(+), 214 deletions(-) diff --git a/server/src/processing.rs b/server/src/processing.rs index a8415b20..f0dfabfb 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -330,7 +330,7 @@ const DOC_BLOCK_SEPARATOR_REMOVE_FENCE: &str = r" const DOC_BLOCK_SEPARATOR_MENDED_FENCE: &str = "\n$1\n"; // The value of an `id` attribute which requests that the cache assign an id; -// see `Auto-assignment of ids` in `cache.rs`. +// see `Auto-assignment of ids` in the cache spec. const AUTO_ID: &str = "*"; // The column at which to word wrap doc blocks. const WORD_WRAP_COLUMN: usize = 80; @@ -426,9 +426,9 @@ pub fn project_cache( // `file_path`, as returned by `find_path_to_toc`. path_to_toc: &Path, ) -> Arc> { - // The project root is the directory containing `toc.md`. Canonicalize it - // so that every file in a project keys the same cache, and different - // projects key different caches. + // The project root is the directory containing `toc.md`. Canonicalize it so + // that every file in a project keys the same cache, and different projects + // key different caches. let toc_path = file_path .parent() .unwrap_or_else(|| Path::new(".")) @@ -940,8 +940,8 @@ pub fn source_to_codechat_for_web( let cache = if let Some(project_cache) = cache { project_cache } else { - // A non-project file uses a throwaway cache whose "project" is the - // file itself: only references within this file resolve. + // A non-project file uses a throwaway cache whose "project" is the file + // itself: only references within this file resolve. Arc::new(Mutex::new(Cache::new(file_path.to_path_buf()))) }; let code_doc_block_arr; @@ -1219,7 +1219,7 @@ static CLEAN_TARGET_HTML: LazyLock = LazyLock::new(|| { b }); -// Clean HTML for storage as a `Fragment`'s content, per the spec in `cache.rs`: +// Clean HTML for storage as a `Fragment`'s content, per the cache spec: // like `AMMONIA_OPTIONS`, but `id` and `data-gather` attributes are stripped // (they aren't in any allowlist) to prevent duplicate ids when the content is // rendered in a gather list, and `` elements are removed *along with @@ -1446,28 +1446,33 @@ fn get_dom_body(document: &Rc) -> Rc { document.children.borrow()[0].children.borrow()[1].clone() } -// HTML produced from Markdown needs additional processing, termed hydration: -// -// * Transform math, Mermaid, GraphViz, etc. nodes. -// * Hydrate cross-references, fragments, and gather elements from the project -// cache. -// * (Eventually) record document structure information. -// * (Eventually) fill in autocomplete fields. -// -// Hydration is layered (see the `Design` section in `cache.rs`); the layers -// which depend on fragment contents (gather lists) can only run after doc -// blocks are finalized, so hydration is split into phases: -// -// 1. `hydrate_dom`: collect facts from the DOM, commit them to the cache, then -// patch `` contents and `` backlinks. -// 2. `store_fragment_contents`: once each doc block's hydrated HTML is known, -// store each fragment's content in the cache. -// 3. `hydrate_gathers`: insert each gather element's list of fragment contents, -// now all present in the cache. -// -// This function runs all three phases for a Markdown document, where phase 2 -// needs no doc block data (fragments aren't allowed in Markdown documents, so -// every fragment's content is an error message). +///

hydrate_html

+/// +/// HTML produced from Markdown needs additional processing, termed hydration: +/// +/// * Transform math, Mermaid, GraphViz, etc. nodes. +/// * Hydrate cross-references, fragments, and gather elements from the project +/// cache. +/// * (Eventually) record document structure information. +/// * (Eventually) fill in autocomplete fields. +/// +/// Hydration is layered (see ); the layers +/// which depend on fragment contents (gather lists) can only run after doc +/// blocks are finalized, so hydration is split into phases: +/// +/// 1. `hydrate_dom`: collect facts from the DOM, commit them to the cache, then +/// patch `` contents and `` backlinks. +/// 2. `store_fragment_contents`: once each doc block's hydrated HTML is known, +/// store each fragment's content in the cache. +/// 3. `hydrate_gathers`: insert each gather element's list of fragment contents, +/// now all present in the cache. +/// +/// Phases 1 and 2 update the cache, and each update may outdate other files; +/// `Cache::mark_outdated` sees that every outdated file is re-rendered. +/// +/// This function runs all three phases for a Markdown document, where phase 2 +/// needs no doc block data (fragments aren't allowed in Markdown documents, so +/// every fragment's content is an error message). fn hydrate_html(html: &str, file: &Path, cache: &Arc>) -> io::Result { let (dom, walk_context) = hydrate_dom(html, file, cache, true)?; store_fragment_contents(&walk_context, None, cache); @@ -1495,13 +1500,12 @@ fn hydrate_dom( let dom = html_to_dom(html, None)?; // Stamp the file and canonicalize its path before taking the cache lock, so // that no I/O happens while the lock is held. The stamp captures the state - // of the file whose content is being processed (the cache's consumer, - // which read that content from the file itself, replaces this with the - // stamp it took beforehand; see `scan_file` in `cache.rs`); the - // canonicalization satisfies the cache's requirement that all paths are - // canonicalized and absolute. (Canonicalization fails for files which - // don't exist on disk -- such as tests -- in which case the path is used - // as-is.) + // of the file whose content is being processed (the cache's consumer, which + // read that content from the file itself, replaces this with the stamp it + // took beforehand; see `scan_file` in `cache.rs`); the canonicalization + // satisfies the cache's requirement that all paths are canonicalized and + // absolute. (Canonicalization fails for files which don't exist on disk -- + // such as tests -- in which case the path is used as-is.) let stamp = file.metadata().ok().as_ref().map(FileStamp::new); let path = canonicalize(file).unwrap_or_else(|_| file.to_path_buf()); // ### Assign requested ids @@ -1509,7 +1513,7 @@ fn hydrate_dom( // Replace each `id="*"` with a freshly generated id before facts are // collected, so that everything downstream sees a real id. These ids reach // the Client but are deliberately kept out of the cache until this file is - // written and re-read; see `Auto-assignment of ids` in `cache.rs`. + // written and re-read; see `Auto-assignment of ids` in the cache spec. let mut auto_ids: HashSet = HashSet::new(); { let cache_guard = cache.lock().unwrap(); @@ -1520,7 +1524,8 @@ fn hydrate_dom( // Walk the DOM, collecting all cacheable facts -- targets, // cross-references, fragments, and gather elements -- without touching the // cache. This keeps the non-`Send` DOM types out of the cache and off its - // critical section; see the design discussion in `cache.rs`. + // critical section. The walk skips the content the cache generates (see + // `hydrating_walk_node`), so hydration never feeds back into the facts. let mut walk_context = WalkContext { facts: FileFacts::default(), path, @@ -1591,9 +1596,9 @@ fn hydrate_dom( } /// Replace every `id="*"` -- a request that the cache assign an id -- with a -/// freshly generated one, recording the ids assigned. Per the spec in -/// `cache.rs`, these ids are placed in the HTML but not recorded in the cache; -/// the fact collection walk uses the recorded set to skip them. +/// freshly generated one, recording the ids assigned. Per the cache spec, these +/// ids are placed in the HTML but not recorded in the cache; the fact +/// collection walk uses the recorded set to skip them. fn assign_auto_ids( // The node whose descendants are scanned. node: &Rc, @@ -1724,19 +1729,19 @@ fn store_fragment_contents( } /// Render one fragment's content: the hydrated HTML of the code/doc blocks it -/// encloses, cleaned per the spec in `cache.rs`. Per that spec, the rendering +/// encloses, cleaned per the cache spec. Per that spec, the rendering /// reproduces the layout of the source it came from: each doc block carries the -/// indent it had there, each line of a code block and the first line of each doc -/// block are preceded by their line numbers, and equal indents in the source -/// line up in the rendering. +/// indent it had there, each line of a code block and the first line of each +/// doc block are preceded by their line numbers, and equal indents in the +/// source line up in the rendering. /// /// ### Why a doc block's indent and a code block are `
`s
 ///
 /// The whitespace in those two *is* the layout, so everything which rewrites
-/// this HTML on its way to the screen must be told to leave it alone -- and both
-/// TinyMCE and the minifier key whitespace sensitivity on the tag name, with no
-/// class- or selector-based equivalent. (`CodeChat-doc-indent` needs no such
-/// treatment: the Client builds it outside the TinyMCE editable region, so
+/// this HTML on its way to the screen must be told to leave it alone -- and
+/// both TinyMCE and the minifier key whitespace sensitivity on the tag name,
+/// with no class- or selector-based equivalent. (`CodeChat-doc-indent` needs no
+/// such treatment: the Client builds it outside the TinyMCE editable region, so
 /// nothing rewrites it.) Of the tag names both tools treat as
 /// whitespace-sensitive by default, `pre` is the one which fits, and each tool
 /// extends that treatment to everything nested inside it, covering the line
@@ -1817,8 +1822,8 @@ fn render_fragment_content(
                     // to line up with the code around it. Only the doc block's
                     // first line is numbered -- the block's remaining source
                     // lines have no fixed correspondence to the lines its
-                    // rendered contents occupy, since the comment delimiters are
-                    // gone and the text reflows.
+                    // rendered contents occupy, since the comment delimiters
+                    // are gone and the text reflows.
                     content.push_str(
                         "
",
                     );
@@ -2361,7 +2366,7 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re
                     });
                 } else {
                     // A fragment without an id: display an error requesting the
-                    // missing id, per the spec in `cache.rs`. Lacking an id, it
+                    // missing id, per the cache spec. Lacking an id, it
                     // can't be cached.
                     walk_context.fragments.push(FragmentHydration {
                         node: child.clone(),
@@ -2398,7 +2403,7 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re
                             id,
                             // Clean the inner HTML for storage; note that it's
                             // captured before any hydration of descendants, per
-                            // the spec in `cache.rs`.
+                            // the cache spec.
                             inner_html: CLEAN_TARGET_HTML
                                 .clean(&node_inner_html(child)?)
                                 .to_string(),
@@ -2421,7 +2426,7 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re
                 }
             } else if get_attr_value(child, "data-gather").is_some() {
                 // `data-gather` on an element without an id: display an error
-                // requesting the missing id, per the spec in `cache.rs`.
+                // requesting the missing id, per the cache spec.
                 walk_context.gathers_error.push((
                     child.clone(),
                     "a gather element requires an \"id\" attribute".to_string(),
diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md
index 70ef8199..9dff602b 100644
--- a/server/src/processing/cache-spec.md
+++ b/server/src/processing/cache-spec.md
@@ -2,8 +2,8 @@ Cache spec
 ==========
 
 The cache stores the location (file name and ID) and contents (inner HTML or
-code/doc blocks for fragments) of a target. Targets are HTML elements with an
-ID.
+code/doc blocks for fragments) of targets and fragments. Targets are HTML
+elements (excluding ``s) with an ID.
 
 The goal of the cache is to support cross-references and gather elements, and to
 assist in ensuring that all IDs are unique within a project. This means that
@@ -37,10 +37,10 @@ is only present in the Client.
 Details:
 
 * This element does not allow any attribute except for `ref`; specifically,
-  an `id` attribute in not allowed.
+  an `id` attribute is not allowed.
 
 * The inner HTML is always taken before cache hydration, to prevent circular
-  dependencies:
+  dependencies (see also ):
 
   ```html
   
@@ -59,8 +59,8 @@ Details:
 * If the cross reference is to a gather element, the text is the gather
   element's inner HTML, not the gathered code/doc blocks.
 
-* Cross references to fragments are not allowed; the fragment contents produced
-  is too verbose. An error message is produced in this case.
+* Cross references to fragments are not allowed; this would create circular
+  dependencies (see ).
 
 Fragments and gather elements
 -----------------------------
@@ -92,19 +92,19 @@ Details:
   contents will be replaced by an error message.
 * Fragments store an HTML rendering of the code and doc blocks they contain,
   excluding the content produced by hydrating the `` tags, to avoid
-  duplication and circular dependencies. See layer 4 under `Design`: this
-  exclusion is what keeps a gather element and the fragments it lists from
-  outdating each other forever. The HTML rendering of a fragment reproduces the
-  layout of the source it came from: each doc block includes its indent, each
-  line of a code block or the first line of a doc block is preceded by that
-  line's number, and the two are aligned -- a doc block and a line of code
-  indented equally in the source begin in the same column, with the line numbers
-  in a gutter of their own to the left of both.
+  duplication and circular dependencies. See layer 4 under
+  : this exclusion is what keeps a gather
+  element and the fragments it lists from outdating each other forever. The HTML
+  rendering of a fragment reproduces the layout of the source it came from: each
+  doc block includes its indent, each line of a code block or the first line of
+  a doc block is preceded by that line's number, and the two are aligned -- a
+  doc block and a line of code indented equally in the source begin in the same
+  column, with the line numbers in a gutter of their own to the left of both.
 * The backlinks a `` hydrates to are derived from the gather elements
   which list it: for each such element, its containing file, its `id`, and its
   inner HTML (the link text). A fragment's rendered output therefore depends on
   the gather elements which reference it -- the reverse of the direction in
-  which references are written; see `Design`.
+  which references are written; see .
 * A `` is valid; it contains only the current doc block.
 * The `following` attribute is clamped if it would exceed the number of code/doc
   blocks in the document.
@@ -161,8 +161,7 @@ The autogenerated id must be a valid
 Autogenerated ids must be checked to make sure they don't collide with an
 existing id.
 
-Cache design
-------------
+

Cache design

The cache is a single plain-data structure, shared as an `Arc>`; all consistency comes from that one lock, so no per-item locking (and therefore @@ -191,35 +190,19 @@ Only layers 1 and 3 are cached, and layer 3 depends only on layer 1 through layer 2. So a change travels at most two steps (C → B → A), which matches the indirection example, ensuring that propagation terminates. -Updating the cache is a multi-phase process: - -1. Collect: while walking a file's DOM, record all cacheable facts - (`FileFacts`) -- targets, cross-references, fragments, and gather elements -- - without touching the cache. This keeps the non-`Send` DOM types out of the - cache and off its critical section. Fact collection ignores content expanded - by the cache (the contents of `xref` and `fragment` tags; the content - following a gather tag). -2. Commit: `Cache::commit_file` applies the initial facts in one transaction, - diffing them against the file's previous state to compute the set of files - outdated by these changes. -3. Update: `update_fragment_content` store a fragment's rendered content, once - the caller has finalized its doc blocks. -4. Hydrate: `hydrate_gathers` marks each gather element with the `cc-gather` - class and insert its list of gathered fragment contents as its next sibling. - -Item 2 requires the cache to track dependents of an id, so that files containing -these dependencies can be marked `Outdated`; these are sent to the -`Cache::consumer_tx` channel for processing. Dependencies are tracked at file -granularity, on the id rather than on the item defining it: -`IdEntry::dependents` is the set of files whose rendered output depends on this -id, by any means -- an `` or a `data-gather` list naming it. One -uniform set suffices in all three `IdState`s because the typical action taken on -a dependent is marking this file outdated (fragments also use their dependents -to generate "See x" links). - -Item 2 also requires the cache to define what constitutes a difference which -would trigger outdating dependencies. The state which is checked for differences -is: +Supporting cross-references, fragments, and gather elements requires the cache +to track dependents of an id, so that files containing these dependencies can be +marked `Outdated` (see below); these are sent to the `Cache::consumer_tx` +channel for processing. Dependencies are tracked at file granularity, on the id +rather than on the item defining it: `IdEntry::dependents` is the set of files +whose rendered output depends on this id, by any means -- an `` +or a `data-gather` list naming it. One uniform set suffices in all three +`IdState`s because the typical action taken on a dependent is marking this file +outdated (fragments also use their dependents to generate "See x" links). + +Updating dependencies requires a definition of what constitutes a difference +which would trigger outdating dependencies. The state which is checked for +differences is: * Target/gather element: type (a target/gather element), path of the containing file, id, IdState, inner HTML, and gather\_ids. @@ -230,6 +213,19 @@ This also makes indirection (gather A includes fragment B, whose content cross-references target C) work without extra machinery: a change to C outdates B's file; reprocessing B's file changes B's content, which outdates A's file. +Updating the cache is a multi-phase process; see +. One output from this process (see +) is a list of outdated files. For each +outdated file, mark its status as `Outdated` (see +) then: + +1. If the outdated file is the project TOC, then send `TocOutdated` to all + connections. +2. If the outdated file is the current file for one or more connections, then + send `CurrentFileOutdated` to those connections. +3. If neither 1 or 2 is true, the `Rescan(file)` message is sent to itself via + the consumer TX channel for later processing. + ### Gather elements: the reverse edge `dependents` alone is not enough for gather elements, because the reference runs @@ -283,8 +279,13 @@ HTML stored as `Target` inner HTML or in `Fragment` contents requires cleaning: * Inner HTML must allow only the [permitted content for an `` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#technical_summary). -Cache, watcher, and walker --------------------------- +Cache population and maintenance +-------------------------------- + +The cache must contain information about all renderable files in the project. +The following design employs a file watcher to update the cache when files +change, and a file walker to populate the cache initially and responds to +directory changes. The `AppState` structure keeps an LRU map of project path -> cache (note that the cache includes a watcher and walker). Adding/removing connections resizes @@ -375,7 +376,7 @@ timestamp tick could leave this metadata unchanged (Git's "racy clean" problem). connections. The file will be translated and updated in the cache when that message is processed. - * If the file is the current file for any of the cache's connection, ignore + * If the file is the current file for any of the cache's connections, ignore it. This is handled by the standard IDE to Client translation mechanism. * If not translatable, the file is still added to the cache, to prevent @@ -384,31 +385,21 @@ timestamp tick could leave this metadata unchanged (Git's "racy clean" problem). * If translatable, it translates the file to the CodeChatForWeb format in order to update the cache for that file. If the `SaveOnScan` option is enabled, it then translates the file back to source code and writes the - result if there are any changes, updating the file's metadata in the cache + result if there are any changes, updating the file's stamp in the cache after the write. This costs additional CPU time and produces watcher events and may rewrite files open in the IDE, motivating the choice of a user-selected option. - - Translating the file produces zero or more outdated files. For each outdated - file, mark its status as `Outdated` then: - - 1. If the outdated file is the project TOC, then send `TocOutdated` to all - connections. - 2. If the outdated file is the current file for one or more connections, - then send `CurrentFileOutdated` to those connections. - 3. If neither 1 or 2 is true, the `Rescan(file)` message is sent to itself - via the consumer TX channel for later processing. * If the path doesn't exist, search the cache for it; if found, remove it. Otherwise, assume the path was a directory. Search the cache for all files in this directory, removing them. * A `Rescan(file)` asks the consumer to bring a file the cache already has up to - date: one a commit outdated (see step 3 above), or a connection's current file - once no connection shows it, since the consumer ignores current files. Unlike - a path from the walker, a `Rescan` hasn't passed the walker's ignore rules. - It's therefore skipped if the cache has since forgotten the file (a walk may - have found it newly ignored, for example), and it doesn't keep a walk in - progress from forgetting the file. + date: one a commit outdated, or a connection's current file once no connection + shows it, since the consumer ignores current files. Unlike a path from the + walker, a `Rescan` hasn't passed the walker's ignore rules. It's therefore + skipped if the cache has since forgotten the file (a walk may have found it + newly ignored, for example), and it doesn't keep a walk in progress from + forgetting the file. * If this is a `WalkStarted`, put all files in the cache which this directory contains in a set. Remove files produced by the walk from this set. After diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index effcf4d6..b676b8f2 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -13,8 +13,8 @@ // You should have received a copy of the GNU General Public License along with // the CodeChat Editor. If not, see // [http://www.gnu.org/licenses](http://www.gnu.org/licenses). -//! `cache.rs` -- Keep a cache used to store all targets in a project -//! ================================================================= +//! `cache.rs` -- Cache all targets and fragments in a project +//! ========================================================== //! //! See the [cache spec](cache-spec.md). // Imports @@ -76,13 +76,14 @@ pub type ProjectCaches = LruCache>>; /// `cache_map_capacity`. pub const LRU_ENTRIES_PER_CONNECTION: usize = 10; -/// This defines the cache used to store all targets in a project. +/// This defines the cache used to store all targets and fragments in a project. pub struct Cache { - /// Provide rapid access to a file by its normalized, absolute path; it must be within - /// the project's root directory. This owns all per-file data. Paths order - /// component by component, so the files beneath a directory immediately - /// follow it in this map: finding them, as a deleted or re-walked directory - /// requires, is a range query rather than a search of every file. + /// Provide rapid access to a file by its normalized, absolute path; it must + /// be within the project's root directory. This owns all per-file data. + /// Paths order component by component, so the files beneath a directory + /// immediately follow it in this map: finding them, as a deleted or + /// re-walked directory requires, is a range query rather than a search of + /// every file. pub(super) files: BTreeMap, /// Provide rapid access to a `Target`/gather element or `Fragment` by its /// id. @@ -95,9 +96,9 @@ pub struct Cache { /// files, and dropping a `ConnectionRegistration` sends the file it showed. /// `None` if there's no consumer -- a single-file "project", or a test. consumer_tx: Option>, - /// This project's watcher, which the watcher/walker thread stores here once it - /// registers the watcher; `None` until then, if there's no consumer, or if - /// the watcher couldn't start. Dropping it stops the watch, which begins + /// This project's watcher, which the watcher/walker thread stores here once + /// it registers the watcher; `None` until then, if there's no consumer, or + /// if the watcher couldn't start. Dropping it stops the watch, which begins /// the shutdown sequence described in the spec. _watcher: Option>, /// The connections whose current file lies in this project, keyed by @@ -132,8 +133,8 @@ pub(super) struct FileEntry { } /// How long after a file's modification time a stamp of it must be taken for -/// that time to be trusted; see `FileStamp::is_racy`. This spans FAT's -/// 2-second timestamp resolution plus some clock skew on network shares. +/// that time to be trusted; see `FileStamp::is_racy`. This spans FAT's 2-second +/// timestamp resolution plus some clock skew on network shares. const RACY_WINDOW: Duration = Duration::from_secs(3); /// A snapshot of a file's metadata, taken immediately before its contents are @@ -159,10 +160,10 @@ pub(super) struct FileStamp { /// supplies the fields Git compares. On Windows, the change time and the file /// index which would stand in for Unix's change time and inode are available /// only in nightly Rust, leaving just the length and modification time. The -/// creation time can't take their place: NTFS caches a file's creation time -/// for a short period after the file is deleted or renamed away, then gives it -/// to a new file which takes that name -- precisely what an editor does when -/// it saves by writing a temporary file, then renaming it over the original. +/// creation time can't take their place: NTFS caches a file's creation time for +/// a short period after the file is deleted or renamed away, then gives it to a +/// new file which takes that name -- precisely what an editor does when it +/// saves by writing a temporary file, then renaming it over the original. /// Windows calls this file system tunneling; see the remarks in /// [`FileSystemInfo.CreationTime`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesysteminfo.creationtime#remarks) /// and Raymond Chen's @@ -172,16 +173,16 @@ struct StatData { len: u64, /// The modification time; `None` if the platform doesn't report one. Two /// `None`s compare equal, which would report every same-length edit as no - /// change, so `FileStamp::new` marks a stamp without a modification time - /// as racy, which keeps it from ever matching. Compare these times only - /// for equality, never order: + /// change, so `FileStamp::new` marks a stamp without a modification time as + /// racy, which keeps it from ever matching. Compare these times only for + /// equality, never order: /// [`SystemTime`](https://doc.rust-lang.org/std/time/struct.SystemTime.html) /// isn't monotonic, so a file saved after another may carry an earlier /// time. modified: Option, - /// The change time as (seconds, nanoseconds). Unlike the modification - /// time, which `touch -d`, `cp -p`, `rsync -t`, and archive extraction set - /// to arbitrary values, no program can set it. + /// The change time as (seconds, nanoseconds). Unlike the modification time, + /// which `touch -d`, `cp -p`, `rsync -t`, and archive extraction set to + /// arbitrary values, no program can set it. #[cfg(unix)] ctime: (i64, i64), /// The device and inode numbers, which change when an editor saves by @@ -199,7 +200,8 @@ pub(super) struct IdEntry { pub(super) state: IdState, /// Every file whose rendered output depends on this id. Note that this does /// *not* capture the reverse dependency of a `Fragment` on the gather - /// elements which list it; see `Design` and `commit_file`. + /// elements which list it; see and + /// `commit_file`. pub(super) dependents: HashSet, } @@ -224,8 +226,7 @@ pub(super) enum IdState { /// The status of a file from the cache's perspective. #[derive(Debug, Default, PartialEq, Eq)] pub(super) enum FileStatus { - /// The file hasn't been processed: an entry's state until its first - /// commit. + /// The file hasn't been processed: an entry's state until its first commit. #[default] Unknown, /// The file needs to be re-processed to update cross-references or gather @@ -235,11 +236,11 @@ pub(super) enum FileStatus { UpToDate, } -/// Contains all information about a target. A target is any HTML element with -/// an id; the id (which should be globally unique within the project; duplicate -/// ids are flagged as errors) is the key of `FileEntry::targets`, and the -/// containing file is the entry holding this value, so neither is duplicated -/// here. +/// Contains all information about a target: any HTML element with an id other +/// than a ``, which is a `Fragment` instead. The id (which should be +/// globally unique within the project; duplicate ids are flagged as errors) is +/// the key of `FileEntry::targets`, and the containing file is the entry +/// holding this value, so neither is duplicated here. pub(super) struct Target { /// The inner HTML of this element. pub(super) inner_html: String, @@ -372,8 +373,8 @@ pub(super) struct GatherBacklink<'a> { // // The watcher reports changed paths to the walker, which forwards those its // ignore files don't exclude -- and the files found by walking each directory -// reported -- to the consumer, which applies them to the cache. See `Cache, -// watcher, and walker` in the spec. +// reported -- to the consumer, which applies them to the cache. See `Cache +// population and maintenance` in the spec. /// A path reported by the watcher. A rename is reported as the deletion of its /// old path followed by the creation of its new one. #[derive(Debug, PartialEq, Eq)] @@ -406,9 +407,9 @@ pub(super) enum ConsumerPathState { // ### Connections // -// A connection's translation task owns its current file; the cache keeps only -// a copy, refreshed by the task each time that file changes. See -// `Connections` in the cache spec. +// A connection's translation task owns its current file; the cache keeps only a +// copy, refreshed by the task each time that file changes. See `Connections` in +// the cache spec. /// One connection whose current file lies in this project. struct ConnectionView { /// The file shown in the connection's Client, canonicalized to match the @@ -450,14 +451,17 @@ pub struct ConnectionRegistration { // Code // ---- /// The capacity of the `CacheMap` when the Server has the given number of -/// connections; `translation_task` applies this as connections come and go. -/// Per the spec, this balances the memory the caches consume against the time +/// connections; `translation_task` applies this as connections come and go. Per +/// the spec, this balances the memory the caches consume against the time /// needed to load the cache of a project which isn't cached. #[must_use] pub fn cache_map_capacity( - // The number of connections, $n$. + // The number of connections, + // \\(n\\). connections: usize, - // $n c + 1$, where $c$ is `LRU_ENTRIES_PER_CONNECTION`. + // \\(n c + 1\\), where + // \\(c\\) is + // `LRU_ENTRIES_PER_CONNECTION`. ) -> NonZeroUsize { NonZeroUsize::MIN.saturating_add(connections.saturating_mul(LRU_ENTRIES_PER_CONNECTION)) } @@ -535,8 +539,8 @@ fn evict_unconnected( impl Cache { /// Create an empty cache with no consumer to keep it current: the cache of - /// a single-file "project", which consists only of that file, or of a - /// test. A project's cache comes from `Cache::new_project`. + /// a single-file "project", which consists only of that file, or of a test. + /// A project's cache comes from `Cache::new_project`. #[must_use] pub fn new( // The root directory of this project, as a canonicalized absolute path; @@ -561,7 +565,7 @@ impl Cache { /// reports through the project's ignore files; and a consumer, which /// applies the walker's results to the cache. The walker's first task is a /// walk of the entire project, so that the consumer brings every file in it - /// into the cache. See `Cache, watcher, and walker` in the spec. + /// into the cache. See `Cache population and maintenance` in the spec. #[must_use] pub fn new_project( // The project's root directory, as a canonicalized absolute path; all @@ -584,8 +588,11 @@ impl Cache { cache } - /// Apply the facts collected from one file's DOM walk to the cache in a - /// single transaction. This satisfies several requirements: + ///

commit_file

+ /// + /// Apply the facts collected + /// from one file's DOM walk to the cache in a single transaction. This + /// satisfies several requirements: /// /// * Determine if any files containing cross-references need to be rebuilt /// due to changes in the `Target`s in this file: any target which was @@ -624,7 +631,7 @@ impl Cache { /// * Otherwise only the symmetric difference is affected: the fragments /// which gained or lost this backlink. /// - /// See the `Design` section for why this doesn't cycle back. + /// See for why this doesn't cycle back. #[allow(clippy::too_many_lines)] pub(super) fn commit_file( &mut self, @@ -934,6 +941,8 @@ impl Cache { outdated } + ///

mark_outdated

+ /// /// Mark files outdated, then see that each is re-rendered. A connection /// re-renders a file it shows -- its current file, or the TOC in its /// sidebar, which every connection in the project shows -- since the text @@ -962,9 +971,9 @@ impl Cache { } } - /// Notify each connection whose current file is one of these files, so - /// that the connection re-translates it. Several connections may share a - /// current file, if it's being edited in each simultaneously. + /// Notify each connection whose current file is one of these files, so that + /// the connection re-translates it. Several connections may share a current + /// file, if it's being edited in each simultaneously. fn notify_current_file_outdated( &self, // The files whose rendered output is outdated. @@ -1043,8 +1052,8 @@ impl Cache { .take_while(move |path| path.starts_with(dir)) } - /// Forget a file which no longer exists, or which the walker now ignores - /// -- unless a connection shows it. The connection's IDE may still hold the + /// Forget a file which no longer exists, or which the walker now ignores -- + /// unless a connection shows it. The connection's IDE may still hold the /// file's text, so instead have the connection re-translate that text, /// which keeps the file in the cache; for the TOC, every connection /// re-loads it. @@ -1178,7 +1187,7 @@ impl IdState { impl FileStamp { pub(super) fn new( - // The file's metadata, read immediately before its contents. + // The file's metadata, which this converts to a `FileStamp`. metadata: &Metadata, ) -> Self { let modified = metadata.modified().ok(); @@ -1260,9 +1269,9 @@ impl Drop for ConnectionRegistration { // The consumer leaves a connection's current file to that connection, // so it may have skipped a change to this file on disk, or its // deletion. Once no connection shows the file, have the consumer check - // the file against the disk. If the file on disk is unchanged, what - // the connection committed from its IDE's text stays in the cache, - // since the IDE may still hold that text. + // the file against the disk. If the file on disk is unchanged, what the + // connection committed from its IDE's text stays in the cache, since + // the IDE may still hold that text. if !cache.is_current_file(&connection.current_file) && let Some(consumer_tx) = &cache.consumer_tx { @@ -1354,8 +1363,8 @@ fn watch_then_walk( let Some(cache) = cache.upgrade() else { return; }; - // The field's leading underscore marks it as held only for what dropping - // it does; this is the one place which sets it. + // The field's leading underscore marks it as held only for what dropping it + // does; this is the one place which sets it. #[allow(clippy::used_underscore_binding)] { cache.lock().unwrap()._watcher = watcher; @@ -1369,8 +1378,8 @@ fn watch_then_walk( Walker::new(root, consumer_tx).run(&walker_rx); } -/// Watch `root` recursively, sending the walker each path created, modified, -/// or deleted within it. The event handler runs on the debouncer's thread. +/// Watch `root` recursively, sending the walker each path created, modified, or +/// deleted within it. The event handler runs on the debouncer's thread. fn watch_project( // The project's root directory. root: &Path, @@ -1421,9 +1430,9 @@ fn watch_project( Ok(debouncer) } -/// Translate one watcher event into the paths it reports to the walker: per -/// the spec, the files created, modified, or deleted, and the directories -/// created or deleted. +/// Translate one watcher event into the paths it reports to the walker: per the +/// spec, the files created, modified, or deleted, and the directories created +/// or deleted. fn watcher_path_states( // The event to translate. event: &Event, @@ -1455,8 +1464,8 @@ fn watcher_path_states( EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { paths.map(Deleted).collect() } - // The platform couldn't tell which half of a rename this is, so ask - // the filesystem. + // The platform couldn't tell which half of a rename this is, so ask the + // filesystem. EventKind::Modify(ModifyKind::Name(_)) => paths .map(|path| { if path.exists() { @@ -1508,9 +1517,9 @@ impl Walker { } } - /// Process each path the watcher sends, until the walker's channel closes - /// -- when the cache is dropped, which drops the watcher, or after the - /// first walk if the watcher couldn't start -- or the consumer exits, + /// Process each path the watcher sends, until the walker's channel + /// closes -- when the cache is dropped, which drops the watcher, or after + /// the first walk if the watcher couldn't start -- or the consumer exits, /// closing its own channel. This runs on the watcher thread; see /// `watch_then_walk`. fn run( @@ -1644,10 +1653,10 @@ impl Walker { /// Build the matcher which decides whether a path the watcher reports is /// ignored. It applies the same filters as every walk: the -/// [ignore](https://docs.rs/ignore) crate's defaults, which are ripgrep's. These -/// skip hidden files and directories (such as `.git`), anything excluded by an -/// `.ignore` file, and -- only inside a Git repository -- anything excluded by -/// `.gitignore` or Git's other exclude rules. +/// [ignore](https://docs.rs/ignore) crate's defaults, which are ripgrep's. +/// These skip hidden files and directories (such as `.git`), anything excluded +/// by an `.ignore` file, and -- only inside a Git repository -- anything +/// excluded by `.gitignore` or Git's other exclude rules. fn build_ignore( // The project's root directory, to which the matcher's paths are relative. root: &Path, @@ -1796,9 +1805,9 @@ fn scan_file( return; } }; - // True if the TOC's text differs from the text the cache last read, which - // a connection's sidebar may still show. When the cache has no record of - // the TOC, no sidebar can show it: loading it in a sidebar records it. + // True if the TOC's text differs from the text the cache last read, which a + // connection's sidebar may still show. When the cache has no record of the + // TOC, no sidebar can show it: loading it in a sidebar records it. let is_toc_changed; { let cache = cache.lock().unwrap(); @@ -1834,8 +1843,8 @@ fn scan_file( // The lexer requires Unix-style line endings. // // TODO: once the Server has user-selected options, implement the spec's - // `SaveOnScan` option here: translate the result back to source, then - // write any changes to the file and update its cached stamp. + // `SaveOnScan` option here: translate the result back to source, then write + // any changes to the file and update its cached stamp. let outcome = match contents.map(|contents| { source_to_codechat_for_web( &contents.replace("\r\n", "\n"), @@ -1859,17 +1868,17 @@ fn scan_file( if cache.is_current_file(path) { // A connection made this file its current file during the translation, // which may then have committed the file's content on disk over the - // content the connection shows, which may be newer. Have the - // connection re-translate its content to restore it. + // content the connection shows, which may be newer. Have the connection + // re-translate its content to restore it. cache.notify_current_file_outdated(&HashSet::from([path.to_path_buf()])); } else { match outcome { - // The translation's commit recorded a stamp taken as of the - // commit, which follows the reading of its text. A write in - // between would leave the cache describing the old text with the - // new stamp, so the consumer would skip the watcher's report of - // that write. Record the stamp taken before the text was read - // instead, which no such write leaves the file matching. + // The translation's commit recorded a stamp taken as of the commit, + // which follows the reading of its text. A write in between would + // leave the cache describing the old text with the new stamp, so + // the consumer would skip the watcher's report of that write. + // Record the stamp taken before the text was read instead, which no + // such write leaves the file matching. ScanOutcome::Translated => { if let Some(entry) = cache.files.get_mut(path) { entry.stamp = Some(stamp); @@ -2980,8 +2989,8 @@ mod tests { vec![] ); - // An ignore file inside an ignored directory can't change what the - // walk finds, so it prompts no walk. + // An ignore file inside an ignored directory can't change what the walk + // finds, so it prompts no walk. let hidden_ignore = root.join(".hidden").join(".ignore"); fs::write(&hidden_ignore, "c.md\n").unwrap(); assert_eq!( @@ -3150,8 +3159,8 @@ mod tests { notify_rx.try_recv(), Ok(CacheNotification::CurrentFileOutdated) ); - // Once no connection shows the file, the consumer is asked to check - // it; this finds it gone, so it's forgotten, which outdates the file + // Once no connection shows the file, the consumer is asked to check it; + // this finds it gone, so it's forgotten, which outdates the file // referencing it. drop(registration); assert_eq!( @@ -3176,7 +3185,8 @@ mod tests { fs::write(&unknown, "

Unknown

\n").unwrap(); fs::write( &directive, - "# CodeChat Editor lexer: python\n#

Directive

\n", + // Split this so the lexer doesn't get set for this file. + concat!("# CodeChat Editor ", "lexer: python\n#

Directive

\n"), ) .unwrap(); for path in [&binary, &unknown, &directive] { @@ -3329,9 +3339,8 @@ mod tests { consumer.apply(&cache, ConsumerPathState::WalkFinished); assert_eq!(cached(&cache), vec![a.clone(), b.clone()]); - // A deletion reported for a path which exists again, as after a - // version control checkout, rescans the file rather than forgetting - // it. + // A deletion reported for a path which exists again, as after a version + // control checkout, rescans the file rather than forgetting it. fs::write(&a, "

Recreated

\n").unwrap(); consumer.apply( &cache, @@ -3364,8 +3373,8 @@ mod tests { } // Verify the whole pipeline: a project's cache learns of its files through - // the initial walk, then of files created and deleted afterward through - // the watcher. + // the initial walk, then of files created and deleted afterward through the + // watcher. #[test] fn test_new_project() { // Wait for `condition` to hold, since the threads which maintain the From 329bcf725a6b3a8ef842a01115e6e6328fe78dc5 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 9 Oct 2026 22:00:23 -0500 Subject: [PATCH 13/14] Cache fixes. Co-author: Claude. --- server/src/lexer.rs | 2 +- server/src/processing.rs | 120 +++++++++++++++++++----- server/src/processing/cache-spec.md | 69 +++++++++----- server/src/processing/cache.rs | 125 ++++++++++++++++++++----- server/src/processing/tests.rs | 139 +++++++++++++++++++++++++++- 5 files changed, 381 insertions(+), 74 deletions(-) diff --git a/server/src/lexer.rs b/server/src/lexer.rs index 3ec01072..8c19da65 100644 --- a/server/src/lexer.rs +++ b/server/src/lexer.rs @@ -704,7 +704,7 @@ pub fn source_lexer( let mut append_code_doc_block = |indent: &str, delimiter: &str, contents: &str| { // Don't append empty entries. if delimiter.is_empty() && contents.is_empty() { - assert!(indent.is_empty()); + assert_eq!(indent, ""); return; } // Define a line as any characters up to an including a newline. If the diff --git a/server/src/processing.rs b/server/src/processing.rs index f0dfabfb..bf23dbcc 100644 --- a/server/src/processing.rs +++ b/server/src/processing.rs @@ -1557,10 +1557,8 @@ fn hydrate_dom( // // Layer 2: each ``'s content depends only on its target's inner HTML, // which the commit just placed in the cache. - for (id, node) in &walk_context.xrefs { - set_attr(node, "contenteditable", "false"); - let children = xref_content(&cache_guard, id, &walk_context.path)?; - set_element_children(node, children); + for node in &walk_context.xrefs { + hydrate_xref(&cache_guard, node, &walk_context.path)?; } // ### Patch fragment backlinks // @@ -1628,23 +1626,86 @@ fn assign_auto_ids( } } +/// Hydrate one ``: mark it non-editable, then replace its contents with a +/// link to its target or an error message. +fn hydrate_xref( + // The cache, already locked by the caller. + cache: &Cache, + // The `` element. + node: &Rc, + // The canonicalized path of the file the `` is rendered in, from + // which its link leads. + path: &Path, + // An error only if the target's stored inner HTML can't be parsed. +) -> io::Result<()> { + set_attr(node, "contenteditable", "false"); + let children = xref_content(cache, node, path)?; + set_element_children(node, children); + Ok(()) +} + +/// Re-hydrate every `` within the given nodes, at any depth, which +/// hydrated to a link; see `hydrate_gathers` for why gathered content needs +/// this. Only a link depends on the file it's rendered in, so one which +/// hydrated to an error message is left as it is. This matters for an `` +/// with an `id`: cleaning a fragment's content strips every `id`, so only the +/// fragment's own file can detect that error. +fn hydrate_xrefs_within( + // The cache, already locked by the caller. + cache: &Cache, + // The nodes to search. + nodes: &[Rc], + // The canonicalized path of the file these nodes are rendered in. + path: &Path, + // An error only if a target's stored inner HTML can't be parsed. +) -> io::Result<()> { + for node in nodes { + if get_node_tag_name(node) == Some("xref") { + let is_error = node + .children + .borrow() + .iter() + .any(|child| has_class(child, "cc-error")); + if !is_error { + hydrate_xref(cache, node, path)?; + } + } else { + hydrate_xrefs_within(cache, &node.children.borrow(), path)?; + } + } + Ok(()) +} + /// The content of one hydrated ``: a link to its target, or an error /// message. fn xref_content( // The cache, already locked by the caller. cache: &Cache, - // The id the cross-reference names. - id: &str, - // The canonicalized path of the file containing the cross-reference. + // The `` element. + node: &Rc, + // The canonicalized path of the file the cross-reference is rendered in. path: &Path, // The nodes to place inside the `` element. ) -> io::Result>> { - if !is_css_identifier(id) { + // An `` is never a target, so an `id` on it -- even `id="*"`, which + // `assign_auto_ids` leaves alone -- would mislead its reader into thinking + // it can be referenced. + if get_attr_value(node, "id").is_some() { + return Ok(vec![error_span( + "a cross-reference may not have an \"id\" attribute", + )]); + } + let Some(id) = get_attr_value(node, "ref") else { + return Ok(vec![error_span( + "a cross-reference requires a \"ref\" attribute", + )]); + }; + if !is_css_identifier(&id) { return Ok(vec![error_span(&format!( "\"{id}\" is not a valid CSS identifier" ))]); } - Ok(match cache.resolve_id(id) { + Ok(match cache.resolve_id(&id) { IdResolution::Target { path: target_path, target, @@ -1711,8 +1772,14 @@ fn store_fragment_contents( cache: &Arc>, ) { let mut cache_guard = cache.lock().unwrap(); + // The ids whose content this pass has stored. Duplicates within this file + // share one entry in the cache, which holds the first in document order + // (see `Cache::commit_file`); storing each duplicate's content in turn + // would instead change that entry twice per pass, outdating every file + // which gathers the id each time this file is processed. + let mut stored_ids: HashSet<&str> = HashSet::new(); for fragment in &walk_context.fragments { - if !fragment.cached { + if !fragment.cached || !stored_ids.insert(&fragment.id) { continue; } let content = if let Some(message) = &fragment.error { @@ -1923,7 +1990,17 @@ fn hydrate_gathers( new_text(":"), ], )); - items.extend(parse_html_fragment(&fragment.content)?); + // The fragment's content was hydrated in the fragment's + // file, so each cross-reference within it links from + // there; re-hydrate them to link from this file instead. + // This file needn't depend on their targets: the + // fragment's stored content embeds everything about a + // target which this re-hydration reads, so any change to + // a target which alters it also changes that content, + // which outdates this file. + let content = parse_html_fragment(&fragment.content)?; + hydrate_xrefs_within(&cache_guard, &content, &walk_context.path)?; + items.extend(content); } IdResolution::Target { .. } => items.push(error_span(&format!( "id \"{id}\" names a target, not a fragment" @@ -2186,9 +2263,9 @@ struct WalkContext { /// These are excluded from `facts`: they belong to the cache only after the /// file carrying them is written and re-read. auto_ids: HashSet, - /// Each cross-reference found: its destination id and its `` node, - /// kept so the generated contents can be patched after the cache commit. - xrefs: Vec<(String, Rc)>, + /// Each cross-reference's `` node, kept so the generated contents + /// can be patched after the cache commit. + xrefs: Vec>, /// Each fragment found, with everything the later hydration phases need. fragments: Vec, /// DOM for all gather elements, i.e. targets with a non-empty @@ -2300,12 +2377,13 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re } else if tag_name == "xref" { // A cross reference: record the referenced id as a fact, and // keep the node so its generated contents can be filled in - // after the cache commit. Note that this element doesn't allow - // an `id` attribute, so it's never a target. + // after the cache commit -- with an error message, if it lacks + // a `ref`. Note that this element is never a target; an `id` + // attribute on it is an error, reported by `xref_content`. if let Some(ref_id) = get_attr_value(child, "ref") { - walk_context.facts.xrefs.push(ref_id.clone()); - walk_context.xrefs.push((ref_id, child.clone())); + walk_context.facts.xrefs.push(ref_id); } + walk_context.xrefs.push(child.clone()); } else if tag_name == "fragment" { // A fragment. Its content can't be determined yet -- doc blocks // aren't finalized during the walk -- so the caller stores it @@ -2350,8 +2428,8 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re walk_context.facts.fragments.push(FragmentFact { id: id.clone(), line: 0, - doc_block_start_index: walk_context.doc_block_index, - code_doc_block_end_index: walk_context + _doc_block_start_index: walk_context.doc_block_index, + _code_doc_block_end_index: walk_context .doc_block_index .saturating_add(following), }); @@ -2411,7 +2489,7 @@ fn hydrating_walk_node(node: &Rc, mut walk_context: WalkContext) -> io::Re // pulldown-cmark HTML writer preserves them; see // the TODO in `cache.rs`. line: 0, - doc_block_index: walk_context.doc_block_index, + _doc_block_index: walk_context.doc_block_index, gather_ids, }); } diff --git a/server/src/processing/cache-spec.md b/server/src/processing/cache-spec.md index 9dff602b..a0dc014a 100644 --- a/server/src/processing/cache-spec.md +++ b/server/src/processing/cache-spec.md @@ -3,7 +3,7 @@ Cache spec The cache stores the location (file name and ID) and contents (inner HTML or code/doc blocks for fragments) of targets and fragments. Targets are HTML -elements (excluding ``s) with an ID. +elements (excluding ``s and ``s) with an ID. The goal of the cache is to support cross-references and gather elements, and to assist in ensuring that all IDs are unique within a project. This means that @@ -17,7 +17,7 @@ do not. The cache reflects data read directly from disk/IDE; content edited in the Client does not update the cache until it's written to disk/IDE, at which point -the cache must re-process this unknown file. Since the Client is designed around +the cache must re-process the file. Since the Client is designed around an autosave principle which updates disk/IDE regularly, there's little gap between the two. @@ -36,8 +36,9 @@ is only present in the Client. Details: -* This element does not allow any attribute except for `ref`; specifically, - an `id` attribute is not allowed. +* This element allows no attribute except for `ref`. Since an `` is + never a `Target`, an `id` attribute on it -- including `id="*"` -- would + mislead, so it replaces the contents with an error message. * The inner HTML is always taken before cache hydration, to prevent circular dependencies (see also ): @@ -92,7 +93,11 @@ Details: contents will be replaced by an error message. * Fragments store an HTML rendering of the code and doc blocks they contain, excluding the content produced by hydrating the `` tags, to avoid - duplication and circular dependencies. See layer 4 under + duplication and circular dependencies. Cross-references in this rendering + are hydrated in the fragment's file, so their links lead from there; a + gather element re-hydrates each which hydrated to a link so that it leads + from the gather element's file instead. An error message reads the same + anywhere, so it's kept as is. See layer 4 under : this exclusion is what keeps a gather element and the fragments it lists from outdating each other forever. The HTML rendering of a fragment reproduces the layout of the source it came from: each @@ -149,12 +154,20 @@ Auto-assignment of ids If `id="*"` on either a fragment, target, or gather element, the cache replaces this with a random autogenerated `id` placed in the resulting HTML contents, but -this new `id` is not yet recorded in the cache. The file is then marked as -`Unknown` when it is saved; when re-read, this `id` is then incorporated into -the cache. This helps avoid cases where the cache and file contents become -unsynchronized: if the `id` is placed in the cache before the write and the -write fails, or if the file is never written (it was being scanned, but not -actively edited, so the file contents weren't written back). +this new `id` is not yet recorded in the cache. It enters the cache only when +source containing it is next translated: the Server translates each edit the +Client saves back to the Client, to check for changes, which commits the edit; +otherwise, the consumer reads the file once it's written to disk. This helps +avoid cases where the cache and file contents become unsynchronized: if the `id` +is placed in the cache before the write and the write fails, or if the file is +never written (it was being scanned, but not actively edited, so the file +contents weren't written back). + +TODO: an `id="*"` typed in the IDE reaches the source only when the Client sends +back the HTML containing its replacement, which it does only once the user edits +in the Client; until then, each translation of the IDE's text assigns a +different id. Resolve this by sending the replacement to the IDE when a +translation assigns ids. The autogenerated id must be a valid [CSS identifier](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/ident). @@ -184,7 +197,11 @@ lower-numbered layers: `id`, and inner HTML. Critically, this output is *excluded* from `Fragment::content` (layer 3); were it included, a gather element and each fragment it lists would outdate one another forever. -5. Gather-list hydration depends on layers 1 and 3. +5. Gather-list hydration depends on layers 1 and 3. It also re-hydrates each + `` within a fragment's content (layer 2) which hydrated to a link, so + that it links from the gathering file. That needs no dependency of its own: everything about a target which + this reads is embedded in the fragment's content, so any change to it also + changes that content, which outdates the gathering file. Only layers 1 and 3 are cached, and layer 3 depends only on layer 1 through layer 2. So a change travels at most two steps (C → B → A), which matches the @@ -205,10 +222,15 @@ which would trigger outdating dependencies. The state which is checked for differences is: * Target/gather element: type (a target/gather element), path of the containing - file, id, IdState, inner HTML, and gather\_ids. + file, id, IdState, and inner HTML. * Fragment: type (a fragment), path of the containing file, id, IdState, and contents. +A gather element's `gather_ids` are absent from this list because no dependent +reads them: a cross-reference to a gather element renders only its inner HTML. +The fragments a gather element lists do depend on them, through the reverse +edge described below. + This also makes indirection (gather A includes fragment B, whose content cross-references target C) work without extra machinery: a change to C outdates B's file; reprocessing B's file changes B's content, which outdates A's file. @@ -372,9 +394,11 @@ timestamp tick could leave this metadata unchanged (Git's "racy clean" problem). * If the file exists and doesn't match its cached stamp or if the file's status is `Outdated`: - * If the file is the TOC, send `TocChanged` or `TocOutdated` to all - connections. The file will be translated and updated in the cache when that - message is processed. + * If the file is the TOC and doesn't match its cached stamp, send + `TocChanged` to all connections once the processing below is done, even if + it ignores the file: each connection's sidebar renders the TOC from the + text it last loaded, which it must therefore re-load (see `Connections` + below). * If the file is the current file for any of the cache's connections, ignore it. This is handled by the standard IDE to Client translation mechanism. @@ -406,6 +430,11 @@ timestamp tick could leave this metadata unchanged (Git's "racy clean" problem). `WalkFinished`, remove any files remaining in the set from the cache, since these no longer exist. +Neither kind of removal applies to a file a connection shows -- its current +file, or the TOC -- since the connection's IDE may still hold the file's text. +Instead, each connection whose current file it is re-translates it, and, if it's +the TOC, every connection re-loads it. + The cache doesn't record images or multimedia files referenced by a source file as dependencies, requiring the user to manually refresh the page when these files change. @@ -451,11 +480,3 @@ TODO: * `SaveOnScan` and search functionality are not implemented yet. This is future work. - -


- -


- -


- -


diff --git a/server/src/processing/cache.rs b/server/src/processing/cache.rs index b676b8f2..f4171bf7 100644 --- a/server/src/processing/cache.rs +++ b/server/src/processing/cache.rs @@ -115,7 +115,8 @@ pub struct Cache { pub(super) struct FileEntry { /// The file's stamp, taken when its content was read; the consumer /// reprocesses the file when it no longer matches. `None` if the file - /// doesn't exist or its metadata can't be determined. + /// doesn't exist, its metadata can't be determined, or the consumer's + /// last translation of it failed. pub(super) stamp: Option, /// The status of this file. pub(super) status: FileStatus, @@ -253,7 +254,7 @@ pub(super) struct Target { /// to find the corresponding Target (the anchor is the id), then use this /// field to identify the line on which that id resides in order to scroll /// to the appropriate location in the document. - pub(super) line: usize, + pub(super) _line: usize, } /// This defines a cross reference to a `Target`. @@ -272,7 +273,7 @@ pub(super) struct Fragment { /// stores the result via `Cache::update_fragment_content`. pub(super) content: String, /// The line number of this `Fragment` in its file; see `Target::line`. - pub(super) line: usize, + pub(super) _line: usize, } // ### Facts @@ -302,7 +303,7 @@ pub(super) struct TargetFact { /// The index of the doc block which contains this `Target` in the vec of /// `CodeDocBlock`s for this file. This is pass-local -- the cache doesn't /// store it -- but the caller needs it to place hydrated content. - pub(super) doc_block_index: usize, + pub(super) _doc_block_index: usize, /// The ids listed by this element's `data-gather` attribute; empty if this /// isn't a gather element. pub(super) gather_ids: Vec, @@ -317,10 +318,10 @@ pub(super) struct FragmentFact { pub(super) line: usize, /// The index of the first doc block of this `Fragment` in the vec of /// `CodeDocBlock`s for this file. - pub(super) doc_block_start_index: usize, + pub(super) _doc_block_start_index: usize, /// The index of the last code/doc block of this `Fragment` in the vec of /// `CodeDocBlock`s for this file. - pub(super) code_doc_block_end_index: usize, + pub(super) _code_doc_block_end_index: usize, } // ### Commit results @@ -331,6 +332,7 @@ pub(super) struct CommitOutcome { /// `Outdated`, and each has been passed on to be re-rendered: to the /// connections showing it, or else to the consumer; see /// `Cache::mark_outdated`. + #[cfg(test)] pub(super) outdated: HashSet, } @@ -354,6 +356,9 @@ pub(super) enum IdResolution<'a> { /// The id is defined more than once: a map from each defining file to the /// number of definitions it contains. Carrying the counts lets the caller /// report a duplicate within a single file. + /// + /// TODO: this is currently unused. + #[allow(dead_code)] Multiple(&'a HashMap), } @@ -701,7 +706,7 @@ impl Cache { Target { inner_html: fact.inner_html, gather_ids: fact.gather_ids, - line: fact.line, + _line: fact.line, }, ); } @@ -722,7 +727,7 @@ impl Cache { fact.id, Fragment { content, - line: fact.line, + _line: fact.line, }, ); } @@ -748,6 +753,9 @@ impl Cache { .chain(old_fragments.keys()) .chain(new_counts.keys()) .collect(); + // The ids this commit left without any definition, which are forgotten + // below unless something references them. + let mut undefined_ids: Vec = Vec::new(); for id in affected_ids { let id_entry = self.ids.entry(id.clone()).or_default(); let mut definitions = id_entry.state.definitions(); @@ -760,6 +768,9 @@ impl Cache { id_entry.state = new_state; outdated.extend(id_entry.dependents.iter().cloned()); } + if id_entry.state == IdState::Missing { + undefined_ids.push(id.clone()); + } } // ### Outdate the dependents of changed definitions @@ -837,6 +848,14 @@ impl Cache { .insert(path.to_path_buf()); } + // ### Forget unused ids + // + // Only now, with this file's references linked, is it known which of + // the ids it stopped defining nothing references. + for id in &undefined_ids { + self.remove_if_unused(id); + } + // ### Mark outdated files // // A file never outdates itself: its rendered output was just produced @@ -844,7 +863,10 @@ impl Cache { outdated.remove(path); self.mark_outdated(&outdated); - CommitOutcome { outdated } + CommitOutcome { + #[cfg(test)] + outdated, + } } /// Remove everything the cache knows about a file -- when the file is @@ -1150,12 +1172,22 @@ impl Cache { ) { if let Some(id_entry) = self.ids.get_mut(id) { id_entry.dependents.remove(referrer); - // An id which nothing defines and nothing references carries no - // information; drop it rather than accumulating an entry for every - // id ever mistyped. - if id_entry.state == IdState::Missing && id_entry.dependents.is_empty() { - self.ids.remove(id); - } + } + self.remove_if_unused(id); + } + + /// Forget an id which nothing defines and nothing references. Its entry + /// carries no information, so keeping it would accumulate an entry for + /// every id ever mistyped, deleted, or renamed. + fn remove_if_unused( + &mut self, + // The id to check. + id: &str, + ) { + if self.ids.get(id).is_some_and(|id_entry| { + id_entry.state == IdState::Missing && id_entry.dependents.is_empty() + }) { + self.ids.remove(id); } } } @@ -1891,9 +1923,15 @@ fn scan_file( ScanOutcome::Untranslatable => { cache.commit_file(path, Some(stamp), FileFacts::default()); } - // Leave the cache as it is: without a stamp matching the file, the - // next walk tries again. - ScanOutcome::Failed => {} + // The translation may have failed after committing the file's + // facts, along with a stamp which matches the file; discard that + // stamp, so that the next walk tries again rather than skipping a + // file whose processing never finished. + ScanOutcome::Failed => { + if let Some(entry) = cache.files.get_mut(path) { + entry.stamp = None; + } + } } } if is_toc_changed { @@ -1974,7 +2012,7 @@ mod tests { id: id.to_string(), inner_html: inner_html.to_string(), line: 0, - doc_block_index: 0, + _doc_block_index: 0, gather_ids: vec![], } } @@ -1992,8 +2030,8 @@ mod tests { FragmentFact { id: id.to_string(), line: 0, - doc_block_start_index: 0, - code_doc_block_end_index: 1, + _doc_block_start_index: 0, + _code_doc_block_end_index: 1, } } @@ -2201,6 +2239,42 @@ mod tests { assert!(!cache.ids.contains_key("nobody")); } + // Verify that an id a file stops defining is forgotten once nothing + // references it -- but not while anything does, including the file which + // stopped defining it. + #[test] + fn test_undefined_id_forgotten() { + let mut cache = Cache::default(); + let a = PathBuf::from("a.py"); + + // A target which nothing references is forgotten once deleted... + cache.commit_file(&a, None, facts_target("foo", "Foo!")); + cache.commit_file(&a, None, FileFacts::default()); + assert!(!cache.ids.contains_key("foo")); + + // ...as is a fragment in a file which is removed. + cache.commit_file(&a, None, facts_fragment("frag")); + cache.remove_file(&a); + assert!(cache.ids.is_empty()); + + // A file which references its own target keeps the id after deleting + // the target, until the reference goes too. + cache.commit_file( + &a, + None, + FileFacts { + targets: vec![target_fact("foo", "Foo!")], + xrefs: vec!["foo".to_string()], + ..Default::default() + }, + ); + cache.commit_file(&a, None, facts_xref("foo")); + assert!(matches!(cache.resolve_id("foo"), IdResolution::Missing)); + assert_eq!(*dependents(&cache, "foo"), paths(&[&a])); + cache.commit_file(&a, None, FileFacts::default()); + assert!(!cache.ids.contains_key("foo")); + } + // ### File lifecycle tests // // Verify that removing a file withdraws both its definitions and its @@ -3186,7 +3260,10 @@ mod tests { fs::write( &directive, // Split this so the lexer doesn't get set for this file. - concat!("# CodeChat Editor ", "lexer: python\n#

Directive

\n"), + concat!( + "# CodeChat Editor ", + "lexer: python\n#

Directive

\n" + ), ) .unwrap(); for path in [&binary, &unknown, &directive] { @@ -3364,9 +3441,9 @@ mod tests { fs::remove_file(&a).unwrap(); consumer.apply( &cache, - ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(a)), + ConsumerPathState::WatcherPathState(WatcherPathState::Deleted(a.clone())), ); - assert!(cached(&cache).is_empty()); + assert_eq!(cached(&cache), Vec::::new()); // Report any errors produced when removing the temporary directory. temp_dir.close().unwrap(); diff --git a/server/src/processing/tests.rs b/server/src/processing/tests.rs index cc638934..552094c0 100644 --- a/server/src/processing/tests.rs +++ b/server/src/processing/tests.rs @@ -50,10 +50,10 @@ use crate::{ CodeDocBlockVecToSourceError, CodeMirrorDiffable, CodeMirrorDocBlockDelete, CodeMirrorDocBlockTransaction, CodeMirrorDocBlockUpdate, CodechatForWebToSourceError, HtmlToMarkdownWrapped, SourceToCodeChatForWebError, UNICODE_CURSOR_MARKER, byte_index_of, - cache::Cache, code_doc_block_vec_to_source, code_mirror_to_code_doc_blocks, - codechat_for_web_to_source, dehydrating_walk_node, diff_code_mirror_doc_blocks, diff_str, - doc_block_html_to_markdown, html_to_dom, hydrate_html, is_css_identifier, markdown_to_html, - source_to_codechat_for_web, + cache::{Cache, FileStatus}, + code_doc_block_vec_to_source, code_mirror_to_code_doc_blocks, codechat_for_web_to_source, + dehydrating_walk_node, diff_code_mirror_doc_blocks, diff_str, doc_block_html_to_markdown, + html_to_dom, hydrate_html, is_css_identifier, markdown_to_html, source_to_codechat_for_web, }, }; use test_utils::{cast, prep_test_dir, test_utils::stringit}; @@ -1808,6 +1808,67 @@ fn test_hydrate_xref_missing() { ); } +// Verify that a cross-reference lacking a `ref` attribute hydrates to an error +// message. +#[test] +fn test_hydrate_xref_without_ref() { + assert_eq!( + source_to_codechat_for_web( + "// See .", + Path::new("foo.js"), + 0.0, + false, + None + ), + Ok(build_codechat_for_web( + "javascript", + "\n", + vec![build_codemirror_doc_block( + 0, + 1, + "", + "//", + "

See a cross-reference requires a \"ref\" attribute." + )] + )) + ); +} + +// Verify that a cross-reference with an `id` attribute hydrates to an error +// message, even when the id requests auto-assignment. +#[test] +fn test_hydrate_xref_with_id() { + assert_eq!( + source_to_codechat_for_web( + "//

Title

\nlet x = 1;\n// See and .", + Path::new("foo.js"), + 0.0, + false, + None + ), + Ok(build_codechat_for_web( + "javascript", + "\nlet x = 1;\n\n", + vec![ + build_codemirror_doc_block(0, 1, "", "//", "

Title

"), + build_codemirror_doc_block( + 12, + 13, + "", + "//", + concat!( + "

See ", + "a cross-reference may not have an \"id\" attribute", + " and ", + "a cross-reference may not have an \"id\" attribute", + "." + ) + ) + ] + )) + ); +} + // Verify that a gather element and the fragment it lists hydrate in a single // pass over their common file: the gather element receives the fragment's // contents (the fragment's doc block plus the following code block), and the @@ -1958,6 +2019,76 @@ fn test_hydrate_xref_cross_file() { ); } +// Verify that a cross-reference within a gathered fragment links from the +// gathering file, not from the fragment's file: here the target shares the +// fragment's file, where the link is just `#t`, while the gathering file lies +// in another directory. A cross-reference which hydrated to an error keeps it, +// even one whose error -- an `id` -- the gathered content no longer shows. +#[test] +fn test_hydrate_gathered_xref() { + let cache = Arc::new(Mutex::new(Cache::default())); + source_to_codechat_for_web( + "//

Title

\nlet a = 1;\n// See and .\nlet b = 2;", + Path::new("b.js"), + 0.0, + false, + Some(cache.clone()), + ) + .unwrap(); + let translation = source_to_codechat_for_web( + "//

Gathered

", + Path::new("sub/a.js"), + 0.0, + false, + Some(cache), + ) + .unwrap(); + let CodeMirrorDiffable::Plain(code_mirror) = translation.source else { + panic!("No diff!"); + }; + assert_eq!( + code_mirror.doc_blocks[0].contents, + concat!( + "

Gathered

", + "
", + "
", + "
3
", + "
", + "

See Title", + " and a cross-reference may not have an \"id\" attribute.", + "

", + "
4let b = 2;
", + "
" + ) + ); +} + +// Verify that, of two fragments sharing an id in one file, the first in +// document order supplies the cached content, and that re-processing the file +// unchanged doesn't outdate the file gathering that id. +#[test] +fn test_duplicate_fragment_in_one_file() { + let cache = Arc::new(Mutex::new(Cache::default())); + let translate = |source: &str, path: &str| { + source_to_codechat_for_web(source, Path::new(path), 0.0, false, Some(cache.clone())) + .unwrap(); + }; + let gathering = "//

Gathered

"; + let fragments = "// First.\nlet a = 1;\n// Second.\nlet b = 2;"; + translate(gathering, "a.js"); + translate(fragments, "b.js"); + // Storing the fragment's content outdated the gathering file; bring it up + // to date, then re-process the fragments' file. + translate(gathering, "a.js"); + translate(fragments, "b.js"); + + let cache = cache.lock().unwrap(); + assert_eq!(cache.files[Path::new("a.js")].status, FileStatus::UpToDate); + let content = &cache.files[Path::new("b.js")].fragments["frag"].content; + assert!(content.contains("First.") && !content.contains("Second.")); +} + // Verify that a fragment in a Markdown document is an error. #[test] fn test_hydrate_fragment_in_markdown() { From 6bacb4b02191c942c9b2616ae91a8e6fbc3d5888 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Fri, 9 Oct 2026 22:02:46 -0500 Subject: [PATCH 14/14] Freeze for release. --- CHANGELOG.md | 6 + Cargo.lock | 348 +++++++------- Cargo.toml | 2 +- README.md | 7 - builder/Cargo.lock | 30 +- client/package.json5 | 38 +- client/pnpm-lock.yaml | 803 ++++++++++++++++--------------- extensions/VSCode/package.json | 18 +- extensions/VSCode/pnpm-lock.yaml | 542 ++++++++++----------- 9 files changed, 930 insertions(+), 864 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a946614..13cc1bd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ Changelog * No changes. +Version 0.3.0 -- 2026-Oct-09 +---------------------------- + +* Full cache implementation -- cache scans all files in project and watches for + changes. + Version 0.2.6-beta1 -- 2026-Sep-22 ---------------------------------- diff --git a/Cargo.lock b/Cargo.lock index ac3e110f..40f2d991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.6" +version = "3.18.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c4f07a021937af76abef4c458a0d6b25f63f82eec5394354deeb7de0551f523" +checksum = "e2ab7d72c0bb26f10df574ea560177b4de2f27ee8f421b2c1e9b75628bd9db23" dependencies = [ "actix-codec", "actix-service", @@ -88,7 +88,7 @@ checksum = "367f814ad4afbac74f07df5001214da65f65e185c90ef56c4dd8df23f8695b9b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -119,9 +119,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.9.5" +version = "2.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aec21d555b23ee78a1c0e4ed61667935de7d61664caba878226a081b1ac20a74" +checksum = "c5c8e46e9b40172e4cc67adcd172bd42e932a5a0aa7f603eee88e60f70452f22" dependencies = [ "actix-rt", "actix-service", @@ -205,7 +205,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -290,9 +290,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ammonia" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" +checksum = "850babe4998b8b9e2dad65a56be4ee1bb977bc3cb3934fa2b28feb2297c30a55" dependencies = [ "cssparser 0.38.0", "html5ever 0.40.1", @@ -421,7 +421,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -566,9 +566,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" dependencies = [ "find-msvc-tools", "jobserver", @@ -650,7 +650,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -674,12 +674,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "codechat-editor-server" -version = "0.2.6-beta1" +version = "0.3.0" dependencies = [ "actix-files", "actix-rt", @@ -699,16 +699,19 @@ dependencies = [ "htmd", "html5ever 0.39.0", "htmlize", + "ignore", "imara-diff", "indoc", "log", "log4rs", + "lru", "markup5ever_rcdom", "mime", "mime_guess", "minify-html", "minreq", "normalize-line-endings", + "notify-debouncer-full", "path-slash", "pest", "pest_derive", @@ -723,7 +726,7 @@ dependencies = [ "sha2", "test_utils", "thirtyfour", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-tungstenite", "tracing", @@ -737,7 +740,7 @@ dependencies = [ [[package]] name = "codechat-editor-standalone" -version = "0.2.6-beta1" +version = "0.3.0" dependencies = [ "actix-http", "actix-rt", @@ -765,7 +768,7 @@ dependencies = [ [[package]] name = "codechat-editor-vscode-extension" -version = "0.2.6-beta1" +version = "0.3.0" dependencies = [ "codechat-editor-server", "log", @@ -996,7 +999,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -1108,7 +1111,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -1143,7 +1146,7 @@ dependencies = [ "dprint-core", "dprint-core-macros", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-width", ] @@ -1176,9 +1179,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "embedded-io" @@ -1194,13 +1197,12 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1256,9 +1258,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1389,7 +1391,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -1521,6 +1523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", + "foldhash 0.2.0", ] [[package]] @@ -1649,13 +1652,12 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http 1.5.0", "http-body", @@ -1684,16 +1686,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http 1.5.0", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1924,9 +1927,9 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" dependencies = [ "rustversion", ] @@ -2015,7 +2018,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -2073,9 +2076,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2134,15 +2137,15 @@ checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -2273,13 +2276,22 @@ dependencies = [ "serde-value", "serde_json", "serde_yaml", - "thiserror 2.0.20", + "thiserror 2.0.21", "thread-id", "typemap-ors", "unicode-segmentation", "winapi", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -2412,9 +2424,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -2428,27 +2440,6 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -2457,9 +2448,9 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" [[package]] name = "napi" -version = "3.12.7" +version = "3.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d3c1107f2fda46a7bd2676d65221b7b8160054494b38554b0e055c731714db" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" dependencies = [ "bitflags", "ctor", @@ -2474,42 +2465,42 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.4.4" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9befcb51d87271051c0c11aaf625d09340266b7f1ac3fef5975e62274dc2813f" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" [[package]] name = "napi-derive" -version = "3.6.8" +version = "3.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e332e701386d8101b093203e296059ad8aeef58ddd4c75594f3c84ce637405e" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" dependencies = [ "convert_case 0.12.0", "ctor", "napi-derive-backend", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] name = "napi-derive-backend" -version = "6.1.4" +version = "6.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6856fe1686867da90ed0dc292d7c0c87a695eb7c578b913360f33619ee01e3" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" dependencies = [ "convert_case 0.12.0", "proc-macro2", "quote", "semver", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] name = "napi-sys" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6801fc65ba4d7035636e93635e281113f00410401a237dcd4f55b1565ead2115" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" dependencies = [ "libloading", ] @@ -2629,9 +2620,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2730,7 +2721,7 @@ dependencies = [ "postcard", "rustc-hash", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2743,7 +2734,7 @@ dependencies = [ "owo-colors", "oxc-miette-derive", "textwrap", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", "unicode-width", ] @@ -3152,19 +3143,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa" dependencies = [ "memchr", + "psm", + "stacker", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f" dependencies = [ "pest", "pest_generator", @@ -3172,22 +3165,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] name = "pest_meta" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1" dependencies = [ "pest", ] @@ -3373,9 +3366,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3441,6 +3434,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.13.4" @@ -3473,7 +3475,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3481,9 +3483,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", @@ -3496,7 +3498,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3504,9 +3506,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -3623,7 +3625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3684,7 +3686,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier 0.7.0", + "rustls-platform-verifier 0.7.1", "serde", "serde_json", "sync_wrapper", @@ -3793,7 +3795,7 @@ dependencies = [ "once_cell", "rustls", "rustls-native-certs", - "rustls-platform-verifier-android", + "rustls-platform-verifier-android 0.1.1", "rustls-webpki", "security-framework", "security-framework-sys", @@ -3803,9 +3805,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3814,7 +3816,7 @@ dependencies = [ "once_cell", "rustls", "rustls-native-certs", - "rustls-platform-verifier-android", + "rustls-platform-verifier-android 0.2.0", "rustls-webpki", "security-framework", "security-framework-sys", @@ -3828,6 +3830,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3954,7 +3962,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -3979,7 +3987,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -4078,9 +4086,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -4090,9 +4098,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smawk" @@ -4116,6 +4124,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4216,9 +4237,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ "proc-macro2", "quote", @@ -4242,7 +4263,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -4314,9 +4335,9 @@ dependencies = [ [[package]] name = "thirtyfour" -version = "0.37.5" +version = "0.37.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b596eb866a1a2bac3e8e17080b8f21739e1bdb0b5479fe5c70d14375362a92" +checksum = "8df97330642045e09a763b839ebedc6e76eb77bf202dad474898f5a631b4dc46" dependencies = [ "arc-swap", "async-trait", @@ -4338,7 +4359,7 @@ dependencies = [ "stringmatch", "tar", "thirtyfour-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -4368,11 +4389,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4388,13 +4409,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -4465,9 +4486,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4488,14 +4509,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4527,9 +4548,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4659,7 +4680,7 @@ version = "12.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", "ts-rs-macros", ] @@ -4688,7 +4709,7 @@ dependencies = [ "log", "rand 0.10.3", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -4720,9 +4741,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-id-start" @@ -4859,9 +4880,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -4883,9 +4904,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4896,19 +4917,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4916,22 +4938,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -4951,9 +4973,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5432,30 +5454,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -5479,15 +5501,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" @@ -5521,7 +5543,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -5582,9 +5604,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.1+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 57472a0b..78661501 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ homepage = "https://codechat-editor.onrender.com/fw/fsb/opt/render/project/src/R keywords = ["literate programming"] license = "GPL-3.0-only" repository = "https://github.com/bjones1/CodeChat_Editor" -version = "0.2.6-beta1" +version = "0.3.0" # Shared dependencies # ------------------- diff --git a/README.md b/README.md index 285595cf..5f254b2d 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,6 @@ cross-reference. For example: | `[Style guide](#cc-nNZ6Gs2uWD)` | [Style guide](#cc-nNZ6Gs2uWD) | | `` | | -Alpha feature: first view the [style guide](docs/style_guide.cpp) to make the -link above work. Opening the link doesn't (yet) work. - In projects, each id must be unique throughout the entire project. To simplify the creation of unique ids, items assigned an `id="*"` with be replaced with a unique id, such as `id=cc-DscjSxRZHF`. This autogenerated id isn't automatically @@ -155,10 +152,6 @@ To do so: Starting websocket ID -Alpha feature: first view [webserver.rs](server/src/webserver.rs) and -[CodeChatEditorFramework.mts](client/src/CodeChatEditorFramework.mts). Opening -the link doesn't (yet) work. - Images ------ diff --git a/builder/Cargo.lock b/builder/Cargo.lock index 73b3f890..d1a211de 100644 --- a/builder/Cargo.lock +++ b/builder/Cargo.lock @@ -128,7 +128,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -258,9 +258,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -283,9 +283,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -295,9 +295,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "log" @@ -440,7 +440,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -462,9 +462,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ "proc-macro2", "quote", @@ -473,22 +473,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] diff --git a/client/package.json5 b/client/package.json5 index d60bd809..b399b07b 100644 --- a/client/package.json5 +++ b/client/package.json5 @@ -43,7 +43,7 @@ url: 'https://github.com/bjones1/CodeChat_editor', }, type: 'module', - version: '0.2.6-beta1', + version: '0.3.0', dependencies: { '@codemirror/commands': '^6.11.1', '@codemirror/lang-cpp': '^6.0.3', @@ -57,18 +57,18 @@ '@codemirror/lang-rust': '^6.0.2', '@codemirror/lang-sql': '^6.10.0', '@codemirror/lang-yaml': '^6.1.3', - '@codemirror/language': '^6.12.4', - '@codemirror/legacy-modes': '^6.5.4', + '@codemirror/language': '^6.13.1', + '@codemirror/legacy-modes': '^6.5.5', '@codemirror/lint': '^6.9.7', - '@codemirror/state': '^6.7.5', - '@codemirror/view': '^6.43.12', - '@hpcc-js/wasm-graphviz': '^1.29.1', + '@codemirror/state': '^6.7.6', + '@codemirror/view': '^6.43.14', + '@hpcc-js/wasm-graphviz': '^1.29.2', '@mathjax/mathjax-newcm-font': '^4.1.3', codemirror: '^6.0.2', mathjax: '^4.1.3', - mermaid: '^11.17.2', - 'pdfjs-dist': '^6.3.289', - tinymce: '^8.9.1', + mermaid: '^12.1.0', + 'pdfjs-dist': '^6.4.299', + tinymce: '^8.9.3', 'toastify-js': '^1.12.0', }, devDependencies: { @@ -76,31 +76,31 @@ '@eslint/js': '^10.0.1', '@types/chai': '^5.2.3', '@types/mocha': '^10.0.10', - '@types/node': '^26.6.2', + '@types/node': '^26.6.4', '@types/toastify-js': '^1.12.4', - '@typescript-eslint/eslint-plugin': '^8.70.0', - '@typescript-eslint/parser': '^8.70.0', + '@typescript-eslint/eslint-plugin': '^8.71.1', + '@typescript-eslint/parser': '^8.71.1', '@typescript/native': 'npm:typescript@^7.0.2', /* The accessibility audit engine driven by the Server's `overall_a11y` browser tests. */ - 'axe-core': '^4.13.0', - chai: '^6.2.2', + 'axe-core': '^4.14.0', + chai: '^6.3.0', esbuild: '^0.28.2', - eslint: '^10.11.0', + eslint: '^10.12.0', 'eslint-config-prettier': '^10.1.8', 'eslint-plugin-import': '^2.32.0', 'eslint-plugin-prettier': '^5.5.6', - globals: '^17.12.0', - mocha: '^12.0.2', + globals: '^17.13.0', + mocha: '^12.0.3', 'npm-check-updates': '^23.1.0', - prettier: '^3.9.8', + prettier: '^3.9.9', /* Typescript 7 support for eslint is blocked per [this issue](https://github.com/typescript-eslint/typescript-eslint/issues/10940). Instead, see [Running Typescript 7.0 Side-by-Side with TypeScript 6.0](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0) for a workaround. */ typescript: 'npm:@typescript/typescript6@^6.0.2', - 'typescript-eslint': '^8.70.0', + 'typescript-eslint': '^8.71.1', }, scripts: { test: 'echo "Error: no test specified" && exit 1', diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 2ec250f5..69d8f88d 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -45,23 +45,23 @@ importers: specifier: ^6.1.3 version: 6.1.3 '@codemirror/language': - specifier: ^6.12.4 - version: 6.12.4 + specifier: ^6.13.1 + version: 6.13.1 '@codemirror/legacy-modes': - specifier: ^6.5.4 - version: 6.5.4 + specifier: ^6.5.5 + version: 6.5.5 '@codemirror/lint': specifier: ^6.9.7 version: 6.9.7 '@codemirror/state': - specifier: ^6.7.5 - version: 6.7.5 + specifier: ^6.7.6 + version: 6.7.6 '@codemirror/view': - specifier: ^6.43.12 - version: 6.43.12 + specifier: ^6.43.14 + version: 6.43.14 '@hpcc-js/wasm-graphviz': - specifier: ^1.29.1 - version: 1.29.1 + specifier: ^1.29.2 + version: 1.29.2 '@mathjax/mathjax-newcm-font': specifier: ^4.1.3 version: 4.1.3 @@ -72,14 +72,14 @@ importers: specifier: ^4.1.3 version: 4.1.3 mermaid: - specifier: ^11.17.2 - version: 11.17.2 + specifier: ^12.1.0 + version: 12.1.0 pdfjs-dist: - specifier: ^6.3.289 - version: 6.3.289 + specifier: ^6.4.299 + version: 6.4.299 tinymce: - specifier: ^8.9.1 - version: 8.9.1 + specifier: ^8.9.3 + version: 8.9.3 toastify-js: specifier: ^1.12.0 version: 1.12.0 @@ -89,7 +89,7 @@ importers: version: 2.0.0 '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.11.0(supports-color@8.1.1)) + version: 10.0.1(eslint@10.12.0(supports-color@8.1.1)) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -97,59 +97,59 @@ importers: specifier: ^10.0.10 version: 10.0.10 '@types/node': - specifier: ^26.6.2 - version: 26.6.2 + specifier: ^26.6.4 + version: 26.6.4 '@types/toastify-js': specifier: ^1.12.4 version: 1.12.4 '@typescript-eslint/eslint-plugin': - specifier: ^8.70.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + specifier: ^8.71.1 + version: 8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) '@typescript-eslint/parser': - specifier: ^8.70.0 - version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + specifier: ^8.71.1 + version: 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 axe-core: - specifier: ^4.13.0 - version: 4.13.0 + specifier: ^4.14.0 + version: 4.14.0 chai: - specifier: ^6.2.2 - version: 6.2.2 + specifier: ^6.3.0 + version: 6.3.0 esbuild: specifier: ^0.28.2 version: 0.28.2 eslint: - specifier: ^10.11.0 - version: 10.11.0(supports-color@8.1.1) + specifier: ^10.12.0 + version: 10.12.0(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.11.0(supports-color@8.1.1)) + version: 10.1.8(eslint@10.12.0(supports-color@8.1.1)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + version: 2.32.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) eslint-plugin-prettier: specifier: ^5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.11.0(supports-color@8.1.1)))(eslint@10.11.0(supports-color@8.1.1))(prettier@3.9.8) + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0(supports-color@8.1.1)))(eslint@10.12.0(supports-color@8.1.1))(prettier@3.9.9) globals: - specifier: ^17.12.0 - version: 17.12.0 + specifier: ^17.13.0 + version: 17.13.0 mocha: - specifier: ^12.0.2 - version: 12.0.2 + specifier: ^12.0.3 + version: 12.0.3 npm-check-updates: specifier: ^23.1.0 version: 23.1.0 prettier: - specifier: ^3.9.8 - version: 3.9.8 + specifier: ^3.9.9 + version: 3.9.9 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' typescript-eslint: - specifier: ^8.70.0 - version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + specifier: ^8.71.1 + version: 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) packages: @@ -165,8 +165,20 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - '@chevrotain/types@11.1.2': - resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/cst-dts-gen@13.2.0': + resolution: {integrity: sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==} + + '@chevrotain/gast@13.2.0': + resolution: {integrity: sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==} + + '@chevrotain/regexp-to-ast@13.2.0': + resolution: {integrity: sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==} + + '@chevrotain/types@13.2.0': + resolution: {integrity: sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==} + + '@chevrotain/utils@13.2.0': + resolution: {integrity: sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==} '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -207,11 +219,11 @@ packages: '@codemirror/lang-yaml@6.1.3': resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} - '@codemirror/language@6.12.4': - resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} - '@codemirror/legacy-modes@6.5.4': - resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==} + '@codemirror/legacy-modes@6.5.5': + resolution: {integrity: sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==} '@codemirror/lint@6.9.7': resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} @@ -219,11 +231,14 @@ packages: '@codemirror/search@6.7.2': resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} - '@codemirror/state@6.7.5': - resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} - '@codemirror/view@6.43.12': - resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + '@codemirror/view@6.43.14': + resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==} '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} @@ -428,8 +443,8 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@hpcc-js/wasm-graphviz@1.29.1': - resolution: {integrity: sha512-koAQL0wlryEMeBs4/cQC3GB5ziITv66gR9DU/SWYjjup+iQ+Nbjcqxeaef+sffN4F4cBYZ3P31B5z7+cbSABbw==} + '@hpcc-js/wasm-graphviz@1.29.2': + resolution: {integrity: sha512-AkArBAZERT5OxH52/+kvCHB07cZ6ZNd9GnOvjw+S880bR88D6i27XfK6xj4dBhUDxNA2QPPMdgEcky3x4TJ3KA==} '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -466,35 +481,35 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@lezer/common@1.5.2': - resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + '@lezer/common@1.5.3': + resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==} '@lezer/cpp@1.1.6': resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==} - '@lezer/css@1.3.7': - resolution: {integrity: sha512-ZuliDOBDNr/WPUL87qyN1BOeAv/ZuF2UxG8xJnlTp+R6ltQo/ybXyIqMK4j3H9bhOCq56mD+yL+dwWdrNOp+oA==} + '@lezer/css@1.3.9': + resolution: {integrity: sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==} '@lezer/go@1.0.1': resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} - '@lezer/highlight@1.2.3': - resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} '@lezer/html@1.3.13': resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} - '@lezer/java@1.1.4': - resolution: {integrity: sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==} + '@lezer/java@1.1.5': + resolution: {integrity: sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==} - '@lezer/javascript@1.5.5': - resolution: {integrity: sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==} + '@lezer/javascript@1.5.6': + resolution: {integrity: sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==} '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} - '@lezer/lr@1.4.10': - resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + '@lezer/lr@1.4.11': + resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==} '@lezer/python@1.1.19': resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==} @@ -511,82 +526,83 @@ packages: '@mathjax/mathjax-newcm-font@4.1.3': resolution: {integrity: sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew==} - '@mermaid-js/parser@1.2.1': - resolution: {integrity: sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==} + '@mermaid-js/parser@2.0.1': + resolution: {integrity: sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==} + engines: {node: '>=22.12.0'} - '@napi-rs/canvas-android-arm64@1.0.9': - resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==} + '@napi-rs/canvas-android-arm64@1.0.10': + resolution: {integrity: sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@1.0.9': - resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==} + '@napi-rs/canvas-darwin-arm64@1.0.10': + resolution: {integrity: sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@1.0.9': - resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==} + '@napi-rs/canvas-darwin-x64@1.0.10': + resolution: {integrity: sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': - resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==} + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': + resolution: {integrity: sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@1.0.9': - resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==} + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': + resolution: {integrity: sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@1.0.9': - resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==} + '@napi-rs/canvas-linux-arm64-musl@1.0.10': + resolution: {integrity: sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': - resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==} + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': + resolution: {integrity: sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@1.0.9': - resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==} + '@napi-rs/canvas-linux-x64-gnu@1.0.10': + resolution: {integrity: sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@1.0.9': - resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==} + '@napi-rs/canvas-linux-x64-musl@1.0.10': + resolution: {integrity: sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@1.0.9': - resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==} + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': + resolution: {integrity: sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@1.0.9': - resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==} + '@napi-rs/canvas-win32-x64-msvc@1.0.10': + resolution: {integrity: sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@1.0.9': - resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==} + '@napi-rs/canvas@1.0.10': + resolution: {integrity: sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==} engines: {node: '>= 10'} '@pkgr/core@0.3.6': @@ -686,8 +702,8 @@ packages: '@types/d3-transition@3.0.9': resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} - '@types/d3-zoom@3.0.8': - resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/d3-zoom@3.0.9': + resolution: {integrity: sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==} '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} @@ -713,8 +729,8 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.6.2': - resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} '@types/toastify-js@1.12.4': resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==} @@ -722,63 +738,63 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@typescript-eslint/eslint-plugin@8.70.0': - resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.70.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.0': - resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.0': - resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.70.0': - resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.70.0': - resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.70.0': - resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.70.0': - resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.70.0': - resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.70.0': - resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.70.0': - resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -913,8 +929,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -960,8 +976,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.13.0: - resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} engines: {node: '>=4'} balanced-match@1.0.2: @@ -996,10 +1012,14 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chevrotain@13.2.0: + resolution: {integrity: sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==} + engines: {node: '>=22.0.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1241,13 +1261,16 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dompurify@3.4.15: - resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -1358,8 +1381,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.11.0: - resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + eslint@10.12.0: + resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1400,9 +1423,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastdom@1.0.12: - resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1467,8 +1487,8 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - globals@17.12.0: - resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + globals@17.13.0: + resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} globalthis@1.0.4: @@ -1527,8 +1547,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.9: - resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} import-meta-resolve@4.2.0: @@ -1663,8 +1683,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@5.4.2: - resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} hasBin: true json-schema-traverse@0.4.1: @@ -1723,8 +1743,9 @@ packages: mdn-data@2.35.0: resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} - mermaid@11.17.2: - resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} + mermaid@12.1.0: + resolution: {integrity: sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==} + engines: {node: '>=22.12.0'} minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} @@ -1740,8 +1761,8 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mocha@12.0.2: - resolution: {integrity: sha512-SjAulGHxlMJLCD1bhvJBAiYwYxglydRzA9PDQ5MALq1oLRMMtIhxSQJ0olCFgA7c9YbTdxC0KOV0wl8JEDYVhA==} + mocha@12.0.3: + resolution: {integrity: sha512-beammieDdiWdkjANWeWPpkxUROYGi5FN88fPOkLiticz7HTg3Y+Pho3EYE6SUdcuWnZ3h2WD8EXCA55JF4o6wg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1804,8 +1825,8 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-manager-detector@1.8.0: - resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -1825,8 +1846,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - pdfjs-dist@6.3.289: - resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==} + pdfjs-dist@6.4.299: + resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==} engines: {node: '>=22.13.0 || >=24'} picocolors@1.1.1: @@ -1854,8 +1875,8 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.9.8: - resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true @@ -1917,8 +1938,8 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-javascript@7.1.1: - resolution: {integrity: sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==} + serialize-javascript@7.1.2: + resolution: {integrity: sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==} engines: {node: '>=20.0.0'} set-function-length@1.2.2: @@ -1957,17 +1978,14 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - strictdom@1.0.1: - resolution: {integrity: sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==} - string.prototype.trim@1.2.11: resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} @@ -2014,8 +2032,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinymce@8.9.1: - resolution: {integrity: sha512-5KrFiuKri2sPQW3ERWodsx3R0RZiw0cJJDr61qaMQiXO1ir3ZYaf6gVVaao+T29spZ9L9waaeE/co8Ov2Rn3vQ==} + tinymce@8.9.3: + resolution: {integrity: sha512-T6Z541dm8R34/rgotB40/j2lwrE3eKARbaoet37/I4d3jq+S7Ul3Q7bNZUQY2WtXOvD9P5aTVbgcYI1nKQoGqg==} toastify-js@1.12.0: resolution: {integrity: sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==} @@ -2053,8 +2071,8 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.70.0: - resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2123,7 +2141,7 @@ snapshots: '@antfu/install-pkg@2.1.0': dependencies: - package-manager-detector: 1.8.0 + package-manager-detector: 1.9.0 tinyexec: 1.3.1 '@braintree/sanitize-url@7.1.2': {} @@ -2140,41 +2158,54 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@chevrotain/types@11.1.2': {} + '@chevrotain/cst-dts-gen@13.2.0': + dependencies: + '@chevrotain/gast': 13.2.0 + '@chevrotain/types': 13.2.0 + + '@chevrotain/gast@13.2.0': + dependencies: + '@chevrotain/types': 13.2.0 + + '@chevrotain/regexp-to-ast@13.2.0': {} + + '@chevrotain/types@13.2.0': {} + + '@chevrotain/utils@13.2.0': {} '@codemirror/autocomplete@6.20.3': dependencies: - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@lezer/common': 1.5.2 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 '@codemirror/commands@6.11.1': dependencies: - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@lezer/common': 1.5.2 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 '@codemirror/lang-cpp@6.0.3': dependencies: - '@codemirror/language': 6.12.4 + '@codemirror/language': 6.13.1 '@lezer/cpp': 1.1.6 '@codemirror/lang-css@6.3.1': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@lezer/common': 1.5.2 - '@lezer/css': 1.3.7 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 + '@lezer/css': 1.3.9 '@codemirror/lang-go@6.0.1': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@lezer/common': 1.5.2 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 '@lezer/go': 1.0.1 '@codemirror/lang-html@6.4.12': @@ -2182,97 +2213,100 @@ snapshots: '@codemirror/autocomplete': 6.20.3 '@codemirror/lang-css': 6.3.1 '@codemirror/lang-javascript': 6.2.5 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@lezer/common': 1.5.2 - '@lezer/css': 1.3.7 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/css': 1.3.9 '@lezer/html': 1.3.13 '@codemirror/lang-java@6.0.2': dependencies: - '@codemirror/language': 6.12.4 - '@lezer/java': 1.1.4 + '@codemirror/language': 6.13.1 + '@lezer/java': 1.1.5 '@codemirror/lang-javascript@6.2.5': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 + '@codemirror/language': 6.13.1 '@codemirror/lint': 6.9.7 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@lezer/common': 1.5.2 - '@lezer/javascript': 1.5.5 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/javascript': 1.5.6 '@codemirror/lang-json@6.0.2': dependencies: - '@codemirror/language': 6.12.4 + '@codemirror/language': 6.13.1 '@lezer/json': 1.0.3 '@codemirror/lang-python@6.2.1': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@lezer/common': 1.5.2 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 '@lezer/python': 1.1.19 '@codemirror/lang-rust@6.0.2': dependencies: - '@codemirror/language': 6.12.4 + '@codemirror/language': 6.13.1 '@lezer/rust': 1.0.3 '@codemirror/lang-sql@6.10.0': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@codemirror/lang-yaml@6.1.3': dependencies: '@codemirror/autocomplete': 6.20.3 - '@codemirror/language': 6.12.4 - '@codemirror/state': 6.7.5 - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@lezer/yaml': 1.0.4 - '@codemirror/language@6.12.4': + '@codemirror/language@6.13.1': dependencies: - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.14 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 style-mod: 4.1.4 - '@codemirror/legacy-modes@6.5.4': + '@codemirror/legacy-modes@6.5.5': dependencies: - '@codemirror/language': 6.12.4 + '@codemirror/streamparser': 6.0.0 '@codemirror/lint@6.9.7': dependencies: - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 crelt: 1.0.7 '@codemirror/search@6.7.2': dependencies: - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 crelt: 1.0.7 - '@codemirror/state@6.7.5': + '@codemirror/state@6.7.6': dependencies: '@marijn/find-cluster-break': 1.0.4 - '@codemirror/view@6.43.12': + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.14': dependencies: - '@codemirror/state': 6.7.5 + '@codemirror/state': 6.7.6 crelt: 1.0.7 style-mod: 4.1.4 w3c-keyname: 2.2.8 @@ -2355,9 +2389,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(supports-color@8.1.1))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0(supports-color@8.1.1))': dependencies: - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2381,7 +2415,7 @@ snapshots: '@eslint/css-tree@4.1.1': dependencies: mdn-data: 2.35.0 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@eslint/css@2.0.0': dependencies: @@ -2389,9 +2423,9 @@ snapshots: '@eslint/css-tree': 4.1.1 '@eslint/plugin-kit': 0.7.3 - '@eslint/js@10.0.1(eslint@10.11.0(supports-color@8.1.1))': + '@eslint/js@10.0.1(eslint@10.12.0(supports-color@8.1.1))': optionalDependencies: - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) '@eslint/object-schema@3.0.5': {} @@ -2400,7 +2434,7 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hpcc-js/wasm-graphviz@1.29.1': {} + '@hpcc-js/wasm-graphviz@1.29.2': {} '@humanfs/core@0.19.2': dependencies: @@ -2434,130 +2468,130 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@lezer/common@1.5.2': {} + '@lezer/common@1.5.3': {} '@lezer/cpp@1.1.6': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 - '@lezer/css@1.3.7': + '@lezer/css@1.3.9': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@lezer/go@1.0.1': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 - '@lezer/highlight@1.2.3': + '@lezer/highlight@1.2.5': dependencies: - '@lezer/common': 1.5.2 + '@lezer/common': 1.5.3 '@lezer/html@1.3.13': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 - '@lezer/java@1.1.4': + '@lezer/java@1.1.5': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 - '@lezer/javascript@1.5.5': + '@lezer/javascript@1.5.6': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@lezer/json@1.0.3': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 - '@lezer/lr@1.4.10': + '@lezer/lr@1.4.11': dependencies: - '@lezer/common': 1.5.2 + '@lezer/common': 1.5.3 '@lezer/python@1.1.19': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@lezer/rust@1.0.3': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@lezer/yaml@1.0.4': dependencies: - '@lezer/common': 1.5.2 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.10 + '@lezer/common': 1.5.3 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.11 '@marijn/find-cluster-break@1.0.4': {} '@mathjax/mathjax-newcm-font@4.1.3': {} - '@mermaid-js/parser@1.2.1': + '@mermaid-js/parser@2.0.1': dependencies: - '@chevrotain/types': 11.1.2 + '@chevrotain/types': 13.2.0 - '@napi-rs/canvas-android-arm64@1.0.9': + '@napi-rs/canvas-android-arm64@1.0.10': optional: true - '@napi-rs/canvas-darwin-arm64@1.0.9': + '@napi-rs/canvas-darwin-arm64@1.0.10': optional: true - '@napi-rs/canvas-darwin-x64@1.0.9': + '@napi-rs/canvas-darwin-x64@1.0.10': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.9': + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': optional: true - '@napi-rs/canvas-linux-arm64-gnu@1.0.9': + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': optional: true - '@napi-rs/canvas-linux-arm64-musl@1.0.9': + '@napi-rs/canvas-linux-arm64-musl@1.0.10': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@1.0.9': + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': optional: true - '@napi-rs/canvas-linux-x64-gnu@1.0.9': + '@napi-rs/canvas-linux-x64-gnu@1.0.10': optional: true - '@napi-rs/canvas-linux-x64-musl@1.0.9': + '@napi-rs/canvas-linux-x64-musl@1.0.10': optional: true - '@napi-rs/canvas-win32-arm64-msvc@1.0.9': + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': optional: true - '@napi-rs/canvas-win32-x64-msvc@1.0.9': + '@napi-rs/canvas-win32-x64-msvc@1.0.10': optional: true - '@napi-rs/canvas@1.0.9': + '@napi-rs/canvas@1.0.10': optionalDependencies: - '@napi-rs/canvas-android-arm64': 1.0.9 - '@napi-rs/canvas-darwin-arm64': 1.0.9 - '@napi-rs/canvas-darwin-x64': 1.0.9 - '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9 - '@napi-rs/canvas-linux-arm64-gnu': 1.0.9 - '@napi-rs/canvas-linux-arm64-musl': 1.0.9 - '@napi-rs/canvas-linux-riscv64-gnu': 1.0.9 - '@napi-rs/canvas-linux-x64-gnu': 1.0.9 - '@napi-rs/canvas-linux-x64-musl': 1.0.9 - '@napi-rs/canvas-win32-arm64-msvc': 1.0.9 - '@napi-rs/canvas-win32-x64-msvc': 1.0.9 + '@napi-rs/canvas-android-arm64': 1.0.10 + '@napi-rs/canvas-darwin-arm64': 1.0.10 + '@napi-rs/canvas-darwin-x64': 1.0.10 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.10 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.10 + '@napi-rs/canvas-linux-arm64-musl': 1.0.10 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-musl': 1.0.10 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.10 + '@napi-rs/canvas-win32-x64-msvc': 1.0.10 optional: true '@pkgr/core@0.3.6': {} @@ -2648,7 +2682,7 @@ snapshots: dependencies: '@types/d3-selection': 3.0.12 - '@types/d3-zoom@3.0.8': + '@types/d3-zoom@3.0.9': dependencies: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.12 @@ -2684,7 +2718,7 @@ snapshots: '@types/d3-time-format': 4.0.3 '@types/d3-timer': 3.0.2 '@types/d3-transition': 3.0.9 - '@types/d3-zoom': 3.0.8 + '@types/d3-zoom': 3.0.9 '@types/deep-eql@4.0.2': {} @@ -2700,7 +2734,7 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@26.6.2': + '@types/node@26.6.4': dependencies: undici-types: 8.9.0 @@ -2709,72 +2743,72 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.70.0 - eslint: 10.11.0(supports-color@8.1.1) - ignore: 7.0.9 + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.71.1 + eslint: 10.12.0(supports-color@8.1.1) + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/project-service@8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.70.0': + '@typescript-eslint/scope-manager@8.71.1': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 - '@typescript-eslint/tsconfig-utils@8.70.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.71.1(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/type-utils@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.70.0': {} + '@typescript-eslint/types@8.71.1': {} - '@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/typescript-estree@8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/project-service': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/project-service': 8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/tsconfig-utils': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.6 semver: 7.8.5 @@ -2784,20 +2818,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/utils@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(supports-color@8.1.1)) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - eslint: 10.11.0(supports-color@8.1.1) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@8.1.1)) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.70.0': + '@typescript-eslint/visitor-keys@8.71.1': dependencies: - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -2869,11 +2903,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - acorn-jsx@5.3.2(acorn@8.18.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} ajv@6.15.0: dependencies: @@ -2942,7 +2976,7 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.13.0: {} + axe-core@4.14.0: {} balanced-match@1.0.2: {} @@ -2984,7 +3018,15 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - chai@6.2.2: {} + chai@6.3.0: {} + + chevrotain@13.2.0: + dependencies: + '@chevrotain/cst-dts-gen': 13.2.0 + '@chevrotain/gast': 13.2.0 + '@chevrotain/regexp-to-ast': 13.2.0 + '@chevrotain/types': 13.2.0 + '@chevrotain/utils': 13.2.0 chokidar@5.0.0: dependencies: @@ -2994,11 +3036,11 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.11.1 - '@codemirror/language': 6.12.4 + '@codemirror/language': 6.13.1 '@codemirror/lint': 6.9.7 '@codemirror/search': 6.7.2 - '@codemirror/state': 6.7.5 - '@codemirror/view': 6.43.12 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.14 commander@7.2.0: {} @@ -3262,7 +3304,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.4.15: + dompurify@3.4.16: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3272,6 +3314,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + elkjs@0.9.3: {} + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -3397,9 +3441,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.11.0(supports-color@8.1.1)): + eslint-config-prettier@10.1.8(eslint@10.12.0(supports-color@8.1.1)): dependencies: - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) eslint-import-resolver-node@0.3.10(supports-color@8.1.1): dependencies: @@ -3409,17 +3453,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - eslint: 10.11.0(supports-color@8.1.1) + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -3428,9 +3472,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 - eslint: 10.11.0(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) eslint-import-resolver-node: 0.3.10(supports-color@8.1.1) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(eslint-import-resolver-node@0.3.10(supports-color@8.1.1))(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 @@ -3442,20 +3486,20 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.11.0(supports-color@8.1.1)))(eslint@10.11.0(supports-color@8.1.1))(prettier@3.9.8): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0(supports-color@8.1.1)))(eslint@10.12.0(supports-color@8.1.1))(prettier@3.9.9): dependencies: - eslint: 10.11.0(supports-color@8.1.1) - prettier: 3.9.8 + eslint: 10.12.0(supports-color@8.1.1) + prettier: 3.9.9 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.11.0(supports-color@8.1.1)) + eslint-config-prettier: 10.1.8(eslint@10.12.0(supports-color@8.1.1)) eslint-scope@9.1.2: dependencies: @@ -3468,9 +3512,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.11.0(supports-color@8.1.1): + eslint@10.12.0(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.7.0 @@ -3505,8 +3549,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -3529,10 +3573,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fastdom@1.0.12: - dependencies: - strictdom: 1.0.1 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -3612,7 +3652,7 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - globals@17.12.0: {} + globals@17.13.0: {} globalthis@1.0.4: dependencies: @@ -3659,7 +3699,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.9: {} + ignore@7.0.12: {} import-meta-resolve@4.2.0: {} @@ -3793,7 +3833,7 @@ snapshots: isexe@2.0.0: {} - js-yaml@5.4.2: + js-yaml@5.4.3: dependencies: argparse: 2.0.1 @@ -3842,13 +3882,14 @@ snapshots: mdn-data@2.35.0: {} - mermaid@11.17.2: + mermaid@12.1.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.7 - '@mermaid-js/parser': 1.2.1 + '@mermaid-js/parser': 2.0.1 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 + chevrotain: 13.2.0 cytoscape: 3.34.3 cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.3) cytoscape-fcose: 2.2.0(cytoscape@3.34.3) @@ -3856,9 +3897,9 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.23 - dompurify: 3.4.15 + dompurify: 3.4.16 + elkjs: 0.9.3 es-toolkit: 1.52.0 - fastdom: 1.0.12 katex: 0.16.47 khroma: 2.1.0 marked: 16.4.2 @@ -3879,7 +3920,7 @@ snapshots: minipass@7.1.3: {} - mocha@12.0.2: + mocha@12.0.3: dependencies: browser-stdout: 1.3.1 chokidar: 5.0.0 @@ -3889,11 +3930,11 @@ snapshots: glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.2 + js-yaml: 5.4.3 minimatch: 10.2.6 ms: 2.1.3 picocolors: 1.1.1 - serialize-javascript: 7.1.1 + serialize-javascript: 7.1.2 strip-json-comments: 5.0.3 supports-color: 8.1.1 workerpool: 10.0.3 @@ -3975,7 +4016,7 @@ snapshots: dependencies: p-limit: 3.1.0 - package-manager-detector@1.8.0: {} + package-manager-detector@1.9.0: {} path-data-parser@0.1.0: {} @@ -3990,9 +4031,9 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 - pdfjs-dist@6.3.289: + pdfjs-dist@6.4.299: optionalDependencies: - '@napi-rs/canvas': 1.0.9 + '@napi-rs/canvas': 1.0.10 picocolors@1.1.1: {} @@ -4013,7 +4054,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.9.8: {} + prettier@3.9.9: {} punycode@2.3.1: {} @@ -4088,7 +4129,7 @@ snapshots: semver@7.8.5: {} - serialize-javascript@7.1.1: {} + serialize-javascript@7.1.2: {} set-function-length@1.2.2: dependencies: @@ -4146,15 +4187,13 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 - strictdom@1.0.1: {} - string.prototype.trim@1.2.11: dependencies: call-bind: 1.0.9 @@ -4204,7 +4243,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinymce@8.9.1: {} + tinymce@8.9.3: {} toastify-js@1.12.0: {} @@ -4257,13 +4296,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1): + typescript-eslint@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0(supports-color@8.1.1))(supports-color@8.1.1) - eslint: 10.11.0(supports-color@8.1.1) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0(supports-color@8.1.1))(supports-color@8.1.1) + eslint: 10.12.0(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color diff --git a/extensions/VSCode/package.json b/extensions/VSCode/package.json index ff33b0e6..215d8c4a 100644 --- a/extensions/VSCode/package.json +++ b/extensions/VSCode/package.json @@ -41,7 +41,7 @@ "type": "git", "url": "https://github.com/bjones1/CodeChat_Editor" }, - "version": "0.2.6", + "version": "0.3.0", "activationEvents": [ "onCommand:extension.codeChatEditorActivate", "onCommand:extension.codeChatEditorDeactivate", @@ -118,26 +118,26 @@ "@emnapi/core": "^1.11.3", "@emnapi/runtime": "^1.11.3", "@eslint/js": "^10.0.1", - "@napi-rs/cli": "^3.10.4", + "@napi-rs/cli": "^3.10.8", "@tybys/wasm-util": "^0.10.4", "@types/escape-html": "^1.0.4", - "@types/node": "^26.6.2", + "@types/node": "^26.6.4", "@types/vscode": "1.61.0", - "@typescript-eslint/eslint-plugin": "^8.70.0", - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/eslint-plugin": "^8.71.1", + "@typescript-eslint/parser": "^8.71.1", "@typescript/native": "npm:typescript@^7.0.2", "@vscode/vsce": "^4.0.0", - "chalk": "^6.0.0", + "chalk": "^6.0.1", "esbuild": "^0.28.2", - "eslint": "^10.11.0", + "eslint": "^10.12.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.5.6", "npm-run-all2": "^9.0.3", - "prettier": "^3.9.8", + "prettier": "^3.9.9", "typescript": "npm:@typescript/typescript6@^6.0.2", - "typescript-eslint": "^8.70.0" + "typescript-eslint": "^8.71.1" }, "optionalDependencies": { "bufferutil": "^4.1.0" diff --git a/extensions/VSCode/pnpm-lock.yaml b/extensions/VSCode/pnpm-lock.yaml index bbe9cf17..594ee514 100644 --- a/extensions/VSCode/pnpm-lock.yaml +++ b/extensions/VSCode/pnpm-lock.yaml @@ -20,10 +20,10 @@ importers: version: 1.11.3 '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.11.0) + version: 10.0.1(eslint@10.12.0) '@napi-rs/cli': - specifier: ^3.10.4 - version: 3.10.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.2) + specifier: ^3.10.8 + version: 3.10.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.4) '@tybys/wasm-util': specifier: ^0.10.4 version: 0.10.4 @@ -31,17 +31,17 @@ importers: specifier: ^1.0.4 version: 1.0.4 '@types/node': - specifier: ^26.6.2 - version: 26.6.2 + specifier: ^26.6.4 + version: 26.6.4 '@types/vscode': specifier: 1.61.0 version: 1.61.0 '@typescript-eslint/eslint-plugin': - specifier: ^8.70.0 - version: 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(@typescript/typescript6@6.0.2)(eslint@10.11.0) + specifier: ^8.71.1 + version: 8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(@typescript/typescript6@6.0.2)(eslint@10.12.0) '@typescript-eslint/parser': - specifier: ^8.70.0 - version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) + specifier: ^8.71.1 + version: 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -49,38 +49,38 @@ importers: specifier: ^4.0.0 version: 4.0.0 chalk: - specifier: ^6.0.0 - version: 6.0.0 + specifier: ^6.0.1 + version: 6.0.1 esbuild: specifier: ^0.28.2 version: 0.28.2 eslint: - specifier: ^10.11.0 - version: 10.11.0 + specifier: ^10.12.0 + version: 10.12.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.11.0) + version: 10.1.8(eslint@10.12.0) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(eslint@10.11.0) + version: 2.32.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(eslint@10.12.0) eslint-plugin-node: specifier: ^11.1.0 - version: 11.1.0(eslint@10.11.0) + version: 11.1.0(eslint@10.12.0) eslint-plugin-prettier: specifier: ^5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.11.0))(eslint@10.11.0)(prettier@3.9.8) + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0))(eslint@10.12.0)(prettier@3.9.9) npm-run-all2: specifier: ^9.0.3 version: 9.0.3 prettier: - specifier: ^3.9.8 - version: 3.9.8 + specifier: ^3.9.9 + version: 3.9.9 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' typescript-eslint: - specifier: ^8.70.0 - version: 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) + specifier: ^8.71.1 + version: 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) optionalDependencies: bufferutil: specifier: ^4.1.0 @@ -124,14 +124,18 @@ packages: resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} engines: {node: '>=22.0.0'} - '@azure/msal-browser@5.22.0': - resolution: {integrity: sha512-5kgu9xeEKgGc2JeidxAtU15NJTqiH/CMCRRQAJ4Rac56kB7KVg91vbNmn+z3RO1vNomPN69UvjKG9h1Pghx6dQ==} + '@azure/msal-browser@5.25.0': + resolution: {integrity: sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==} engines: {node: '>=0.8.0'} '@azure/msal-common@16.14.1': resolution: {integrity: sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==} engines: {node: '>=0.8.0'} + '@azure/msal-common@16.14.2': + resolution: {integrity: sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==} + engines: {node: '>=0.8.0'} + '@azure/msal-node@6.0.1': resolution: {integrity: sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==} engines: {node: '>=20'} @@ -384,12 +388,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@2.0.8': - resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} + '@inquirer/ansi@2.0.9': + resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.5': - resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==} + '@inquirer/checkbox@5.2.6': + resolution: {integrity: sha512-XkOardciF3tow2NUlY6/Qjk1QqYa+6WpmVLTpxXl97Uv4erczDEZ+xg1j6LqBf0ekcRSUiICQA4nQXHHkVKQ9w==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -397,8 +401,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.3.2': - resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} + '@inquirer/confirm@6.3.3': + resolution: {integrity: sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -406,8 +410,8 @@ packages: '@types/node': optional: true - '@inquirer/core@12.0.3': - resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} + '@inquirer/core@12.0.4': + resolution: {integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -415,8 +419,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.3.3': - resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} + '@inquirer/editor@5.3.4': + resolution: {integrity: sha512-imzxQDfw26NpcJAubMj/dE5RjU9Mp80IcT+FzsD6YoxXmL8L1QYnAFdCNv7ccGWh//73+0YP+1kfhwnExdl2VQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -424,8 +428,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.5': - resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} + '@inquirer/expand@5.1.6': + resolution: {integrity: sha512-JfiLKi7qGuHmcQO/DqR10UFEp054Y1FXZaszP5ZEfHhgHeuFoT5nNFQIXFhed/5/p07SbsV2tIJFwG/dg5+NmQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -433,8 +437,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.5': - resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} + '@inquirer/external-editor@3.0.6': + resolution: {integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -446,8 +450,8 @@ packages: resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.6': - resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==} + '@inquirer/input@5.1.7': + resolution: {integrity: sha512-Icrs9tuMBCxGI+lFYNO71VFOnqGO8IIrLIGqnTGh07X8jZ2la9QLAYxXcikHZQjjVhIZUtU2xyH89DVN/pPhVg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -455,8 +459,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.2.3': - resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} + '@inquirer/number@4.2.4': + resolution: {integrity: sha512-C6g80U4U2+w2Vxa1CB0MN3a2WnQIfzLSUG0lduO3Au9GsirJsKATZkOwZPK+vZ1BlAlz+/Z5ParfQCS9E0svmg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -464,8 +468,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.2.2': - resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} + '@inquirer/password@5.2.3': + resolution: {integrity: sha512-1Uo2qfI2K3wXbfunMpvwAzoZPIUf5iEETwDGqRVbswoorXGqYQJ/bfIJFeLj+pxjq+e2UFPNOmx+fsIl4Urigw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -473,8 +477,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.7.2': - resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} + '@inquirer/prompts@8.7.3': + resolution: {integrity: sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -482,8 +486,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.5': - resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} + '@inquirer/rawlist@5.3.6': + resolution: {integrity: sha512-2exExhQ5xTBqfN/rRlZkvM1Gm3b0eiLcWe2NqxWxe7qYmnU4M3j/n9TwzfPUb+Q7dAAz9TmBtDWWdF3jbp4ZLA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -491,8 +495,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.3.3': - resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} + '@inquirer/search@4.3.4': + resolution: {integrity: sha512-xsk0UeFP04jsbEeFMzghjUaH2aHXyt+WykSajQzq/8qyBNHsgGYHea/sFwv5T08kW7Sp+KDX8aF124zPUIs1Ww==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -500,8 +504,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.5': - resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} + '@inquirer/select@5.2.6': + resolution: {integrity: sha512-jFu7CFghlr0vpi/326a4wz6BGkJH9X3H/iIu+ahyV2Ja52ktqQE3fC9BYP47JKeSPQYGUv5xquE2Vp9PbepZ3g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -509,8 +513,8 @@ packages: '@types/node': optional: true - '@inquirer/type@4.1.1': - resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} + '@inquirer/type@4.2.0': + resolution: {integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -527,8 +531,8 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@napi-rs/cli@3.10.4': - resolution: {integrity: sha512-7yvXpu/m4p1Re1/DViAooSYAyivYEhqDrjuq66a6haXLEAKrZSzndG7B850g1irngI1pAdZS5xL4tsu0xQ8P9Q==} + '@napi-rs/cli@3.10.8': + resolution: {integrity: sha512-hg7m8VwECTIIHxNzajDG8QgtTOEVAKiIVLCLCW90Th6Z5R/ijWjh16iaQrRvXj4ve/S2fcXYo5cctCWJ8aVxIg==} engines: {node: ^20.17.0 || ^22.13.0 || >= 23.5.0} hasBin: true peerDependencies: @@ -876,8 +880,8 @@ packages: resolution: {integrity: sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.2.4': - resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 @@ -1074,69 +1078,69 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@26.6.2': - resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} '@types/vscode@1.61.0': resolution: {integrity: sha512-9k5Nwq45hkRwdfCFY+eKXeQQSbPoA114mF7U/4uJXRBJeGIO7MuJdhF1PnaDN+lllL9iKGQtd6FFXShBXMNaFg==} - '@typescript-eslint/eslint-plugin@8.70.0': - resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.70.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.70.0': - resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.70.0': - resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.70.0': - resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.70.0': - resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.70.0': - resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.70.0': - resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.70.0': - resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.70.0': - resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.70.0': - resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -1325,8 +1329,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -1413,8 +1417,8 @@ packages: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} engines: {node: '>=18'} cacheable@2.5.0: @@ -1432,8 +1436,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - chalk@6.0.0: - resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} engines: {node: '>=22'} chardet@2.2.0: @@ -1674,8 +1678,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.11.0: - resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + eslint@10.12.0: + resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1845,8 +1849,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.9: - resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} imurmurhash@0.1.4: @@ -1985,8 +1989,8 @@ packages: resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} engines: {node: '>=4'} - js-yaml@5.4.2: - resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} hasBin: true json-parse-even-better-errors@6.0.0: @@ -2055,8 +2059,8 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - marked@18.0.13: - resolution: {integrity: sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==} + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} engines: {node: '>= 20'} hasBin: true @@ -2203,8 +2207,8 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.9.8: - resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} engines: {node: '>=14'} hasBin: true @@ -2243,8 +2247,8 @@ packages: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + resolve@1.22.13: + resolution: {integrity: sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==} engines: {node: '>= 0.4'} hasBin: true @@ -2312,8 +2316,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.10.0: - resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -2420,8 +2424,8 @@ packages: typed-rest-client@1.8.11: resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} - typescript-eslint@8.70.0: - resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2578,7 +2582,7 @@ snapshots: '@azure/core-tracing': 1.4.0 '@azure/core-util': 1.14.0 '@azure/logger': 1.4.0 - '@azure/msal-browser': 5.22.0 + '@azure/msal-browser': 5.25.0 '@azure/msal-node': 6.0.1 open: 10.2.0 tslib: 2.8.1 @@ -2592,12 +2596,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.22.0': + '@azure/msal-browser@5.25.0': dependencies: - '@azure/msal-common': 16.14.1 + '@azure/msal-common': 16.14.2 '@azure/msal-common@16.14.1': {} + '@azure/msal-common@16.14.2': {} + '@azure/msal-node@6.0.1': dependencies: '@azure/msal-common': 16.14.1 @@ -2738,9 +2744,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0)': dependencies: - eslint: 10.11.0 + eslint: 10.12.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2761,9 +2767,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.11.0)': + '@eslint/js@10.0.1(eslint@10.12.0)': optionalDependencies: - eslint: 10.11.0 + eslint: 10.12.0 '@eslint/object-schema@3.0.5': {} @@ -2788,124 +2794,124 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@2.0.8': {} + '@inquirer/ansi@2.0.9': {} - '@inquirer/checkbox@5.2.5(@types/node@26.6.2)': + '@inquirer/checkbox@5.2.6(@types/node@26.6.4)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@26.6.2) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@26.6.4) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/confirm@6.3.2(@types/node@26.6.2)': + '@inquirer/confirm@6.3.3(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/core@12.0.3(@types/node@26.6.2)': + '@inquirer/core@12.0.4(@types/node@26.6.4)': dependencies: - '@inquirer/ansi': 2.0.8 + '@inquirer/ansi': 2.0.9 '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/type': 4.2.0(@types/node@26.6.4) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/editor@5.3.3(@types/node@26.6.2)': + '@inquirer/editor@5.3.4(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/external-editor': 3.0.5(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/external-editor': 3.0.6(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/expand@5.1.5(@types/node@26.6.2)': + '@inquirer/expand@5.1.6(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/external-editor@3.0.5(@types/node@26.6.2)': + '@inquirer/external-editor@3.0.6(@types/node@26.6.4)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.6(@types/node@26.6.2)': + '@inquirer/input@5.1.7(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/number@4.2.3(@types/node@26.6.2)': + '@inquirer/number@4.2.4(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/password@5.2.2(@types/node@26.6.2)': + '@inquirer/password@5.2.3(@types/node@26.6.4)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 - - '@inquirer/prompts@8.7.2(@types/node@26.6.2)': - dependencies: - '@inquirer/checkbox': 5.2.5(@types/node@26.6.2) - '@inquirer/confirm': 6.3.2(@types/node@26.6.2) - '@inquirer/editor': 5.3.3(@types/node@26.6.2) - '@inquirer/expand': 5.1.5(@types/node@26.6.2) - '@inquirer/input': 5.1.6(@types/node@26.6.2) - '@inquirer/number': 4.2.3(@types/node@26.6.2) - '@inquirer/password': 5.2.2(@types/node@26.6.2) - '@inquirer/rawlist': 5.3.5(@types/node@26.6.2) - '@inquirer/search': 4.3.3(@types/node@26.6.2) - '@inquirer/select': 5.2.5(@types/node@26.6.2) + '@types/node': 26.6.4 + + '@inquirer/prompts@8.7.3(@types/node@26.6.4)': + dependencies: + '@inquirer/checkbox': 5.2.6(@types/node@26.6.4) + '@inquirer/confirm': 6.3.3(@types/node@26.6.4) + '@inquirer/editor': 5.3.4(@types/node@26.6.4) + '@inquirer/expand': 5.1.6(@types/node@26.6.4) + '@inquirer/input': 5.1.7(@types/node@26.6.4) + '@inquirer/number': 4.2.4(@types/node@26.6.4) + '@inquirer/password': 5.2.3(@types/node@26.6.4) + '@inquirer/rawlist': 5.3.6(@types/node@26.6.4) + '@inquirer/search': 4.3.4(@types/node@26.6.4) + '@inquirer/select': 5.2.6(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/rawlist@5.3.5(@types/node@26.6.2)': + '@inquirer/rawlist@5.3.6(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/search@4.3.3(@types/node@26.6.2)': + '@inquirer/search@4.3.4(@types/node@26.6.4)': dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.2) + '@inquirer/core': 12.0.4(@types/node@26.6.4) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/select@5.2.5(@types/node@26.6.2)': + '@inquirer/select@5.2.6(@types/node@26.6.4)': dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/core': 12.0.3(@types/node@26.6.2) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.4(@types/node@26.6.4) '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@26.6.2) + '@inquirer/type': 4.2.0(@types/node@26.6.4) optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 - '@inquirer/type@4.1.1(@types/node@26.6.2)': + '@inquirer/type@4.2.0(@types/node@26.6.4)': optionalDependencies: - '@types/node': 26.6.2 + '@types/node': 26.6.4 '@keyv/bigmap@1.3.1(keyv@5.6.0)': dependencies: @@ -2915,16 +2921,16 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@napi-rs/cli@3.10.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.2)': + '@napi-rs/cli@3.10.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.6.4)': dependencies: - '@inquirer/prompts': 8.7.2(@types/node@26.6.2) + '@inquirer/prompts': 8.7.3(@types/node@26.6.4) '@napi-rs/cross-toolchain': 1.0.3 '@napi-rs/wasm-tools': 1.1.0 '@octokit/rest': 22.0.1 clipanion: 4.0.0-rc.4(typanion@3.14.0) colorette: 2.0.20 es-toolkit: 1.52.0 - js-yaml: 5.4.2 + js-yaml: 5.4.3 obug: 3.0.0 semver: 7.8.5 typanion: 3.14.0 @@ -3048,7 +3054,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@napi-rs/lzma-win32-arm64-msvc@1.5.1': @@ -3120,7 +3126,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@napi-rs/tar-win32-arm64-msvc@1.1.1': @@ -3151,14 +3157,14 @@ snapshots: '@napi-rs/tar-win32-ia32-msvc': 1.1.1 '@napi-rs/tar-win32-x64-msvc': 1.1.1 - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.4 optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 @@ -3196,7 +3202,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0': @@ -3335,78 +3341,78 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@26.6.2': + '@types/node@26.6.4': dependencies: undici-types: 8.9.0 '@types/vscode@1.61.0': {} - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(@typescript/typescript6@6.0.2)(eslint@10.11.0)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(@typescript/typescript6@6.0.2)(eslint@10.12.0)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - '@typescript-eslint/visitor-keys': 8.70.0 - eslint: 10.11.0 - ignore: 7.0.9 + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + '@typescript-eslint/visitor-keys': 8.71.1 + eslint: 10.12.0 + ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0)': + '@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0)': dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3 - eslint: 10.11.0 + eslint: 10.12.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/project-service@8.71.1(@typescript/typescript6@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.70.0': + '@typescript-eslint/scope-manager@8.71.1': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 - '@typescript-eslint/tsconfig-utils@8.70.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.71.1(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0)': + '@typescript-eslint/type-utils@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0)': dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) debug: 4.4.3 - eslint: 10.11.0 + eslint: 10.12.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.70.0': {} + '@typescript-eslint/types@8.71.1': {} - '@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/typescript-estree@8.71.1(@typescript/typescript6@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/project-service': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 @@ -3416,20 +3422,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0)': + '@typescript-eslint/utils@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2) - eslint: 10.11.0 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2) + eslint: 10.12.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.70.0': + '@typescript-eslint/visitor-keys@8.71.1': dependencies: - '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -3558,7 +3564,7 @@ snapshots: commander: 12.1.0 hosted-git-info: 4.1.0 jsonc-parser: 3.3.1 - marked: 18.0.13 + marked: 18.1.0 mime: 1.6.0 minimatch: 10.2.6 parse5: 8.0.1 @@ -3574,11 +3580,11 @@ snapshots: transitivePeerDependencies: - supports-color - acorn-jsx@5.3.2(acorn@8.18.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} agent-base@7.1.4: {} @@ -3684,7 +3690,7 @@ snapshots: node-gyp-build: 4.8.4 optional: true - bundle-name@4.1.0: + bundle-name@4.1.1: dependencies: run-applescript: 7.1.0 @@ -3713,7 +3719,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - chalk@6.0.0: {} + chalk@6.0.1: {} chardet@2.2.0: {} @@ -3771,7 +3777,7 @@ snapshots: default-browser@5.5.1: dependencies: - bundle-name: 4.1.0 + bundle-name: 4.1.1 default-browser-id: 5.0.1 define-data-property@1.1.4: @@ -3935,9 +3941,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.11.0): + eslint-config-prettier@10.1.8(eslint@10.12.0): dependencies: - eslint: 10.11.0 + eslint: 10.12.0 eslint-import-resolver-node@0.3.10: dependencies: @@ -3947,23 +3953,23 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(eslint-import-resolver-node@0.3.10)(eslint@10.11.0): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(eslint-import-resolver-node@0.3.10)(eslint@10.12.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - eslint: 10.11.0 + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + eslint: 10.12.0 eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-es@3.0.1(eslint@10.11.0): + eslint-plugin-es@3.0.1(eslint@10.12.0): dependencies: - eslint: 10.11.0 + eslint: 10.12.0 eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(eslint@10.11.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(eslint@10.12.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.2.0 @@ -3972,9 +3978,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 10.11.0 + eslint: 10.12.0 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(eslint-import-resolver-node@0.3.10)(eslint@10.11.0) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(eslint-import-resolver-node@0.3.10)(eslint@10.12.0) hasown: 2.0.4 is-core-module: 2.17.0 is-glob: 4.0.3 @@ -3986,30 +3992,30 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-node@11.1.0(eslint@10.11.0): + eslint-plugin-node@11.1.0(eslint@10.12.0): dependencies: - eslint: 10.11.0 - eslint-plugin-es: 3.0.1(eslint@10.11.0) + eslint: 10.12.0 + eslint-plugin-es: 3.0.1(eslint@10.12.0) eslint-utils: 2.1.0 ignore: 5.3.2 minimatch: 3.1.5 - resolve: 1.22.12 + resolve: 1.22.13 semver: 6.3.1 - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.11.0))(eslint@10.11.0)(prettier@3.9.8): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.12.0))(eslint@10.12.0)(prettier@3.9.9): dependencies: - eslint: 10.11.0 - prettier: 3.9.8 + eslint: 10.12.0 + prettier: 3.9.9 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.11.0) + eslint-config-prettier: 10.1.8(eslint@10.12.0) eslint-scope@9.1.2: dependencies: @@ -4028,9 +4034,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.11.0: + eslint@10.12.0: dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 @@ -4065,8 +4071,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -4231,7 +4237,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.9: {} + ignore@7.0.12: {} imurmurhash@0.1.4: {} @@ -4373,7 +4379,7 @@ snapshots: editions: 6.22.0 textextensions: 6.11.0 - js-yaml@5.4.2: + js-yaml@5.4.3: dependencies: argparse: 2.0.1 @@ -4446,7 +4452,7 @@ snapshots: dependencies: yallist: 4.0.0 - marked@18.0.13: {} + marked@18.1.0: {} math-intrinsics@1.1.0: {} @@ -4492,7 +4498,7 @@ snapshots: picomatch: 4.0.7 pidtree: 1.0.0 read-package-json-fast: 6.0.0 - shell-quote: 1.10.0 + shell-quote: 1.12.0 which: 7.0.0 object-inspect@1.13.4: {} @@ -4592,7 +4598,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.9.8: {} + prettier@3.9.9: {} proper-lockfile@4.1.2: dependencies: @@ -4642,7 +4648,7 @@ snapshots: regexpp@3.2.0: {} - resolve@1.22.12: + resolve@1.22.13: dependencies: es-errors: 1.3.0 is-core-module: 2.17.0 @@ -4719,7 +4725,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.10.0: {} + shell-quote@1.12.0: {} side-channel-list@1.0.1: dependencies: @@ -4862,13 +4868,13 @@ snapshots: tunnel: 0.0.6 underscore: 1.13.8 - typescript-eslint@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0): + typescript-eslint@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0))(@typescript/typescript6@6.0.2)(eslint@10.11.0) - '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.11.0) - eslint: 10.11.0 + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0))(@typescript/typescript6@6.0.2)(eslint@10.12.0) + '@typescript-eslint/parser': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + '@typescript-eslint/typescript-estree': 8.71.1(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.71.1(@typescript/typescript6@6.0.2)(eslint@10.12.0) + eslint: 10.12.0 typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color