diff --git a/.eslintrc.js b/.eslintrc.js index f28b61017..8a309d2bc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,8 @@ const config = { '**/build/**', 'package-lock.json', '**/scripts/ts-json-schema-generator.cjs', + '**/fixtures/**/*.js', + '**/webpack*.js', ], plugins: ['import', 'unicorn', 'simple-import-sort'], rules: { @@ -57,7 +59,7 @@ const config = { }, overrides: [ { - files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx'], + files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx', '**/*.js', '**/*.mjs'], extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/typescript'], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], diff --git a/cspell-words.txt b/cspell-words.txt index 75a6a09df..65d598c63 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1,4 +1,5 @@ # Project Words +abortable acanthopterygious activitybar aeiouy @@ -63,13 +64,16 @@ nohoist noreply nospace onfinally +optionator outfile overridable OVSX paginator permalinks +Positionals Preformat preinstall +Pseudoterminal quickfix quotemark readonly @@ -95,6 +99,8 @@ tsbuildInfo tslib typechecking unelevated +unindented +unindents untracked untrusted uri's diff --git a/cspell.config.yaml b/cspell.config.yaml index c6044b46c..bc696532d 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -53,6 +53,9 @@ overrides: ignoreWords: - colour - canot + - filename: '**/*.test.*' + dictionaries: + - lorem-ipsum useGitignore: true words: - dbaeumer diff --git a/docs/_includes/generated-docs/commands.md b/docs/_includes/generated-docs/commands.md index a2f5de02d..4d154fa27 100644 --- a/docs/_includes/generated-docs/commands.md +++ b/docs/_includes/generated-docs/commands.md @@ -19,6 +19,7 @@ | `cSpell.addWordToWorkspaceSettings` | Add Words to Workspace Settings | | `cSpell.autoFixSpellingIssues` | Fix all issues with a preferred suggestion in the current document. | | `cSpell.createCSpellConfig` | Create a CSpell Configuration File. | +| `cSpell.createCSpellTerminal` | Create CSpell REPL Terminal | | `cSpell.createCustomDictionary` | Create a Custom Dictionary File. | | `cSpell.disableCurrentFileType` | Disable Spell Checking File Type | | `cSpell.disableCurrentLanguage` | Disable Spell Checking Document Language | diff --git a/docs/_scripts/extract-commands.js b/docs/_scripts/extract-commands.mjs similarity index 82% rename from docs/_scripts/extract-commands.js rename to docs/_scripts/extract-commands.mjs index 57382f119..9dadcffb4 100644 --- a/docs/_scripts/extract-commands.js +++ b/docs/_scripts/extract-commands.mjs @@ -1,7 +1,10 @@ -// eslint-disable-next-line node/no-unpublished-require -const package = require('../../package.json'); +import { createRequire } from 'module'; -const commands = package.contributes.commands; +const require = createRequire(import.meta.url); + +const pkgJson = require('../../package.json'); + +const commands = pkgJson.contributes.commands; const compare = new Intl.Collator().compare; diff --git a/docs/_scripts/package.json b/docs/_scripts/package.json new file mode 100644 index 000000000..ab8cb3a65 --- /dev/null +++ b/docs/_scripts/package.json @@ -0,0 +1,6 @@ +{ + "type": "commonjs", + "engines": { + "node": ">=18" + } +} diff --git a/docs/package.json b/docs/package.json index 8e9350259..18c967eb6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,7 @@ "test": "echo Skip Docs", "gen-docs": "npm run gen-config-docs && npm run gen-command-docs && npm run lint", "gen-config-docs": "node _scripts/extract-config.mjs > _includes/generated-docs/configuration.md", - "gen-command-docs": "node _scripts/extract-commands.js > _includes/generated-docs/commands.md", + "gen-command-docs": "node _scripts/extract-commands.mjs > _includes/generated-docs/commands.md", "lint": "prettier -w \"**/*.{md,markdown,yaml,yml,json,html,htm,js}\"", "serve": "bundle exec jekyll serve" }, diff --git a/package-lock.json b/package-lock.json index de7c41f8b..e81f6eb12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,7 +86,6 @@ }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2851,6 +2850,12 @@ "@types/node": "*" } }, + "node_modules/@types/camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha512-DNQK/AZLPLp1/AKaU1zIpBEe1NmpdHuxSM2oAZjtQPg5p4erIZOoeG+tKEjN6h8Ujugs3FfOBMzTT2f2avOW2g==", + "dev": true + }, "node_modules/@types/chai": { "version": "4.3.14", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", @@ -4844,6 +4849,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5340,6 +5353,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001578", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", @@ -5869,7 +5890,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -6871,7 +6891,6 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "dev": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -8290,7 +8309,6 @@ }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "dev": true, "license": "MIT" }, "node_modules/fastest-levenshtein": { @@ -10923,7 +10941,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -13043,8 +13060,8 @@ }, "node_modules/optionator": { "version": "0.9.3", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dependencies": { "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", @@ -13540,7 +13557,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -13620,14 +13636,20 @@ "dev": true, "license": "MIT" }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==" + }, "node_modules/process-nextick-args": { "version": "2.0.1", "license": "MIT" }, "node_modules/prompts": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", "dev": true, - "license": "MIT", "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -16107,7 +16129,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -17845,8 +17866,8 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, - "license": "MIT", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -17862,7 +17883,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -18203,10 +18223,15 @@ "@cspell/cspell-types": "^8.7.0", "@internal/common-utils": "file:../__utils", "@internal/settings-webview": "file:../_settingsViewer", + "ansi-escapes": "^6.2.0", + "ansi-styles": "^6.2.1", + "as-table": "^1.0.55", + "camelize": "^1.0.1", "code-spell-checker-server": "file:../_server", "comment-json": "^4.2.3", "fast-deep-equal": "^3.1.3", "kefir": "^3.8.8", + "optionator": "^0.9.3", "utils-disposables": "file:../utils-disposables", "utils-logger": "file:../utils-logger", "vscode-jsonrpc": "^8.2.0", @@ -18214,7 +18239,8 @@ "vscode-uri": "^3.0.8", "vscode-webview-rpc": "file:../webview-rpc", "webview-api": "file:../webview-api", - "yaml": "^2.4.1" + "yaml": "^2.4.1", + "yargs": "^17.7.2" }, "bin": { "build": "build.mjs" @@ -18222,6 +18248,7 @@ "devDependencies": { "@internal/cspell-helper": "file:../__cspell-helper", "@internal/locale-resolver": "file:../__locale-resolver", + "@types/camelize": "^1.0.0", "@types/kefir": "^3.8.11", "@types/source-map-support": "^0.5.10", "cross-env": "^7.0.3", @@ -18238,6 +18265,42 @@ "node": ">18.0.0" } }, + "packages/client/node_modules/ansi-escapes": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz", + "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==", + "dependencies": { + "type-fest": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/client/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/client/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/json-rpc-api": { "version": "1.0.0", "license": "MIT", diff --git a/package.json b/package.json index 6d4a20134..bb1a60690 100644 --- a/package.json +++ b/package.json @@ -565,6 +565,12 @@ "category": "Spell", "title": "Toggle Trace Mode", "icon": "$(search)" + }, + { + "command": "cSpell.createCSpellTerminal", + "category": "Spell", + "title": "Create CSpell REPL Terminal", + "icon": "$(terminal)" } ], "languages": [ diff --git a/packages/_server/src/DocumentValidationController.mts b/packages/_server/src/DocumentValidationController.mts index ee6463a33..c0bb9cafc 100644 --- a/packages/_server/src/DocumentValidationController.mts +++ b/packages/_server/src/DocumentValidationController.mts @@ -33,13 +33,21 @@ export class DocumentValidationController { return this.docValMap.get(doc.uri); } - getDocumentValidator(docInfo: TextDocumentInfoWithText | TextDocument) { + delete(doc: TextDocumentRef) { + this.docValMap.delete(doc.uri); + } + + getDocumentValidator(docInfo: TextDocumentInfoWithText | TextDocument, cache = true) { const uri = docInfo.uri; const docValEntry = this.docValMap.get(uri); - if (docValEntry) return docValEntry.docVal; + if (docValEntry) { + return docValEntry.docVal; + } const entry = this.createDocValEntry(docInfo); - this.docValMap.set(uri, entry); + if (cache) { + this.docValMap.set(uri, entry); + } return entry.docVal; } @@ -61,7 +69,7 @@ export class DocumentValidationController { } private handleOnDidClose(e: TextDocumentChangeEvent) { - this.docValMap.delete(e.document.uri); + this.delete(e.document); } private handleOnDidChangeContent(e: TextDocumentChangeEvent) { @@ -69,7 +77,10 @@ export class DocumentValidationController { } private async _handleOnDidChangeContent(e: TextDocumentChangeEvent) { - const { document } = e; + await this.updateDocument(e.document); + } + + async updateDocument(document: TextDocument) { const entry = this.docValMap.get(document.uri); if (!entry) return; const { settings, docVal } = entry; @@ -80,6 +91,7 @@ export class DocumentValidationController { return; } await _docVal.updateDocumentText(document.getText()); + return _docVal; } } diff --git a/packages/_server/src/api/api.ts b/packages/_server/src/api/api.ts index 9d980f87d..cc73a619a 100644 --- a/packages/_server/src/api/api.ts +++ b/packages/_server/src/api/api.ts @@ -13,6 +13,8 @@ import type { import { createClientApi, createServerApi } from 'json-rpc-api'; import type { + CheckDocumentOptions, + CheckDocumentResult, GetConfigurationForDocumentRequest, GetConfigurationForDocumentResult, GetSpellCheckingOffsetsResult, @@ -44,6 +46,7 @@ export interface ServerRequestsAPI { */ getSpellCheckingOffsets(doc: TextDocumentRef): GetSpellCheckingOffsetsResult; traceWord(req: TraceWordRequest): TraceWordResult; + checkDocument(doc: TextDocumentInfo, options?: CheckDocumentOptions): CheckDocumentResult; } /** Notifications that can be sent to the server */ diff --git a/packages/_server/src/api/apiModels.ts b/packages/_server/src/api/apiModels.ts index 46c805b39..0efd09ee4 100644 --- a/packages/_server/src/api/apiModels.ts +++ b/packages/_server/src/api/apiModels.ts @@ -2,6 +2,7 @@ import type { PublishDiagnosticsParams } from 'vscode-languageserver'; import type { ConfigScopeVScode, ConfigTarget } from '../config/configTargets.mjs'; import type * as config from '../config/cspellConfig/index.mjs'; +import type { CheckDocumentIssue } from './models/Diagnostic.mjs'; import type { Suggestion } from './models/Suggestion.mjs'; import type { ExtensionId } from './models/types.mjs'; @@ -13,6 +14,7 @@ export type { ConfigTargetDictionary, ConfigTargetVSCode, } from '../config/configTargets.mjs'; +export type { CheckDocumentIssue } from './models/Diagnostic.mjs'; export type { Position, Range } from 'vscode-languageserver-types'; export interface BlockedFileReason { @@ -215,18 +217,67 @@ export type VSCodeSettingsCspell = { export type PublishDiagnostics = Required; export interface TraceWordRequest { + /** + * URL to a document or directory. + * The configuration is determined by the languageId and configuration files relative to the uri. + */ uri: DocumentUri; + /** + * The word to look up in the dictionaries. + */ word: string; + /** + * The languageId to use if the uri is a directory. + * @default document languageId or 'plaintext' + */ + languageId?: string | undefined; + /** + * Search all known dictionaries for the word. + * @default false + */ + searchAllDictionaries?: boolean | undefined; + /** + * Search for compound words. + */ + allowCompoundWords?: boolean | undefined; } export interface Trace { + /** + * The word searched for in the dictionary. + */ word: string; + /** + * true if found in the dictionary. + */ found: boolean; + /** + * The actual word found in the dictionary. + */ foundWord: string | undefined; + /** + * true if the word is forbidden. + */ forbidden: boolean; + /** + * true if it is a no-suggest word. + */ noSuggest: boolean; + /** + * name of the dictionary + */ dictName: string; + /** + * Path or URL to the dictionary. + */ dictSource: string; + /** + * true if the dictionary is enabled for the languageId (file type). + */ + dictEnabled: boolean; + /** + * The errors found while looking up the word. + */ errors: string | undefined; } @@ -247,3 +298,17 @@ export interface TraceWordResult { splits?: readonly TraceWordFound[]; errors?: string | undefined; } + +export interface CheckDocumentOptions { + /** + * Force a check even if the document would normally be excluded. + */ + forceCheck?: boolean; +} + +export interface CheckDocumentResult { + uri: DocumentUri; + errors?: string; + skipped?: boolean; + issues?: CheckDocumentIssue[]; +} diff --git a/packages/_server/src/api/models/Diagnostic.mts b/packages/_server/src/api/models/Diagnostic.mts index 503e737ae..e0e9736a7 100644 --- a/packages/_server/src/api/models/Diagnostic.mts +++ b/packages/_server/src/api/models/Diagnostic.mts @@ -1,5 +1,5 @@ import type { IssueType } from '@cspell/cspell-types'; -import type { Diagnostic } from 'vscode-languageserver-types'; +import type { Diagnostic, Range } from 'vscode-languageserver-types'; import type { Suggestion } from './Suggestion.mjs'; import type { DiagnosticSource } from './types.mjs'; @@ -19,3 +19,8 @@ export interface SpellingDiagnostic extends Diagnostic { source: DiagnosticSource; data: SpellCheckerDiagnosticData; } + +export interface CheckDocumentIssue extends SpellCheckerDiagnosticData { + text: string; + range: Range; +} diff --git a/packages/_server/src/handleCheckDocumentRequest.ts b/packages/_server/src/handleCheckDocumentRequest.ts new file mode 100644 index 000000000..bf362c584 --- /dev/null +++ b/packages/_server/src/handleCheckDocumentRequest.ts @@ -0,0 +1,56 @@ +import type { CSpellSettings } from 'cspell-lib'; +import type { TextDocument } from 'vscode-languageserver-textdocument'; + +import type * as Api from './api.js'; +import type { DocumentValidationController } from './DocumentValidationController.mjs'; +import { readTextDocument } from './vfs/index.mjs'; +import { toTextDocument } from './vfs/readTextDocument.mjs'; + +export async function handleCheckDocumentRequest( + docValidationController: DocumentValidationController, + docRef: Api.TextDocumentInfo, + options: Api.CheckDocumentOptions, + getCachedDoc: (uri: string) => TextDocument | undefined, + shouldCheck: (doc: Api.TextDocumentInfo, settings: CSpellSettings) => boolean | Promise, +): Promise { + const { uri } = docRef; + const checkSettings = !options.forceCheck && (await docValidationController.documentSettings.getSettings(docRef)); + if (checkSettings && !(await shouldCheck(docRef, checkSettings))) { + return { uri, skipped: true }; + } + const doc = await getDoc(); + if (!doc) return { uri, errors: 'Document Not Found.' }; + + const docVal = await docValidationController.getDocumentValidator(doc, false); + + if (!docVal.getFinalizedDocSettings().enabled && !options.forceCheck) { + return { uri, skipped: true }; + } + + const results = docVal.checkDocument(options.forceCheck); + + const issues: Api.CheckDocumentIssue[] = results.map((issue) => ({ + text: issue.text, + range: { start: doc.positionAt(issue.offset), end: doc.positionAt(issue.offset + (issue.length || issue.text.length)) }, + suggestions: issue.suggestionsEx, + message: issue.message, + })); + + return { uri, issues }; + + async function getDoc() { + if (docRef.text) { + const text = docRef.text; + return toTextDocument({ ...docRef, text }); + } + let doc = getCachedDoc(docRef.uri); + if (!doc) { + try { + return await readTextDocument(docRef.uri, docRef.languageId); + } catch { + doc = undefined; + } + } + return doc; + } +} diff --git a/packages/_server/src/server.mts b/packages/_server/src/server.mts index b8018b261..d5c7cc79c 100644 --- a/packages/_server/src/server.mts +++ b/packages/_server/src/server.mts @@ -47,11 +47,12 @@ import { isScmUri } from './config/docUriHelper.mjs'; import type { TextDocumentUri } from './config/vscode.config.mjs'; import { defaultCheckLimit } from './constants.mjs'; import { DocumentValidationController } from './DocumentValidationController.mjs'; +import { handleCheckDocumentRequest } from './handleCheckDocumentRequest.js'; import { createProgressNotifier } from './progressNotifier.mjs'; import type { PartialServerSideHandlers } from './serverApi.mjs'; import { createServerApi } from './serverApi.mjs'; import { createOnSuggestionsHandler } from './suggestionsServer.mjs'; -import { traceWord } from './trace.js'; +import { handleTraceRequest } from './trace.js'; import { defaultIsTextLikelyMinifiedOptions, isTextLikelyMinified } from './utils/analysis.mjs'; import { catchPromise } from './utils/catchPromise.mjs'; import { debounce as simpleDebounce } from './utils/debounce.mjs'; @@ -114,6 +115,7 @@ export function run(): void { getConfigurationForDocument: handleGetConfigurationForDocument, getSpellCheckingOffsets: simpleDebounce(_handleGetSpellCheckingOffsets, 100, ({ uri }) => uri), traceWord: simpleDebounce(_handleGetWordTrace, 100, ({ uri, word }) => uri + '|' + word), + checkDocument: simpleDebounce(_handleCheckDocument, 100, ({ uri }) => uri), isSpellCheckEnabled: handleIsSpellCheckEnabled, splitTextIntoWords: handleSplitTextIntoWords, spellingSuggestions: createOnSuggestionsHandler(documents, { @@ -407,11 +409,12 @@ export function run(): void { } async function _handleGetWordTrace(req: Api.TraceWordRequest): Promise { - const { word, uri } = req; - log(`_handleGetWordTrace "${word}"`, uri); - const doc = documents.get(uri); - if (!doc) return { word, errors: 'Document Not Found.' }; - return traceWord(docValidationController, doc, word); + log(`_handleGetWordTrace "${req.word}"`, req.uri); + return handleTraceRequest(docValidationController, req, (uri) => documents.get(uri)); + } + + async function _handleCheckDocument(doc: Api.TextDocumentInfo, options?: Api.CheckDocumentOptions): Promise { + return handleCheckDocumentRequest(docValidationController, doc, options || {}, (uri) => documents.get(uri), shouldValidateDocument); } async function getExcludedBy(uri: string): Promise { @@ -454,17 +457,19 @@ export function run(): void { catchPromise(connection.sendDiagnostics(diagsForVSCode), 'sendDiagnostics'); } - async function shouldValidateDocument(textDocument: TextDocument, settings: CSpellUserSettings): Promise { + type ShouldValidateDocument = Pick & Partial; + + async function shouldValidateDocument(textDocument: ShouldValidateDocument, settings: CSpellUserSettings): Promise { const { uri, languageId } = textDocument; return ( !!settings.enabled && - isLanguageEnabled(languageId, settings) && + (!languageId || isLanguageEnabled(languageId, settings)) && !(await isUriExcluded(uri)) && !isBlocked(textDocument, settings) ); } - function isBlocked(textDocument: TextDocument, settings: CSpellUserSettings): boolean { + function isBlocked(textDocument: ShouldValidateDocument, settings: CSpellUserSettings): boolean { const { uri } = textDocument; const { blockCheckingWhenLineLengthGreaterThan = defaultIsTextLikelyMinifiedOptions.blockCheckingWhenLineLengthGreaterThan, @@ -475,11 +480,13 @@ export function run(): void { log(`File is blocked ${blockedFiles.get(uri)?.message}`, uri); return true; } - const isMiniReason = isTextLikelyMinified(textDocument.getText(), { - blockCheckingWhenAverageChunkSizeGreaterThan, - blockCheckingWhenLineLengthGreaterThan, - blockCheckingWhenTextChunkSizeGreaterThan, - }); + const isMiniReason = + textDocument.getText && + isTextLikelyMinified(textDocument.getText(), { + blockCheckingWhenAverageChunkSizeGreaterThan, + blockCheckingWhenLineLengthGreaterThan, + blockCheckingWhenTextChunkSizeGreaterThan, + }); if (isMiniReason) { blockedFiles.set(uri, isMiniReason); diff --git a/packages/_server/src/serverApi.mts b/packages/_server/src/serverApi.mts index 0a336674b..ebd0057c6 100644 --- a/packages/_server/src/serverApi.mts +++ b/packages/_server/src/serverApi.mts @@ -14,6 +14,7 @@ export function createServerApi(connection: MessageConnection, handlers: Partial splitTextIntoWords: true, spellingSuggestions: true, traceWord: true, + checkDocument: true, ...handlers.serverRequests, }, serverNotifications: { diff --git a/packages/_server/src/test/test.api.ts b/packages/_server/src/test/test.api.ts index 81d8d3d99..595b77523 100644 --- a/packages/_server/src/test/test.api.ts +++ b/packages/_server/src/test/test.api.ts @@ -17,6 +17,7 @@ export function createMockServerSideApi() { getSpellCheckingOffsets: { subscribe: vi.fn() }, spellingSuggestions: { subscribe: vi.fn() }, traceWord: { subscribe: vi.fn() }, + checkDocument: { subscribe: vi.fn() }, }, clientNotification: { onSpellCheckDocument: vi.fn(), @@ -66,6 +67,9 @@ export function mockHandlers(): ServerSideHandlers { spellingSuggestions: vi.fn(() => ({ suggestions: [] })), getSpellCheckingOffsets: vi.fn(() => ({ offsets: [] })), traceWord: vi.fn((req) => ({ word: req.word, traces: [] })), + checkDocument(_doc, _options) { + return { uri: 'uri' }; + }, }, }; } diff --git a/packages/_server/src/trace.ts b/packages/_server/src/trace.ts index a3b2d6d58..2ea4b7564 100644 --- a/packages/_server/src/trace.ts +++ b/packages/_server/src/trace.ts @@ -1,22 +1,58 @@ import { groupByField } from '@internal/common-utils'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; +import type { CSpellSettings, DocumentValidator, TraceOptions } from 'cspell-lib'; +import { traceWordsAsync } from 'cspell-lib'; +import { TextDocument } from 'vscode-languageserver-textdocument'; import type * as Api from './api.js'; import type { DocumentValidationController } from './DocumentValidationController.mjs'; +import { readTextDocument, stat } from './vfs/index.mjs'; + +export interface TraceWordOptions extends TraceOptions { + searchAllDictionaries?: boolean; +} export async function traceWord( docValidationController: DocumentValidationController, doc: TextDocument, word: string, + options: TraceWordOptions | undefined, ): Promise { - const docVal = await docValidationController.getDocumentValidator(doc); + const docVal = await docValidationController.getDocumentValidator(doc, false); + const { searchAllDictionaries, ...traceOptions } = options || {}; + if (!searchAllDictionaries && !Object.keys(traceOptions).length) { + return simpleDocTrace(docVal, word); + } + + const finalizedSettings = docVal.getFinalizedDocSettings(); + + const { dictionaries, enabled } = extractDictionaryList(finalizedSettings); + const settings = { ...docVal.getFinalizedDocSettings(), dictionaries }; + + const enabledDicts = new Set(enabled); + + const traceResult = await trace(word, settings, traceOptions); + if (!traceResult) return { word, errors: 'No trace result.' }; + + const byWord = groupByField(traceResult, 'word'); + const traces: Exclude = [...byWord.entries()].map(([word, traces]) => ({ + word, + found: isFound(traces), + traces: traces.map((t) => ({ ...t, errors: errorsToString(t.errors), dictEnabled: enabledDicts.has(t.dictName) })), + })); + + const splits = traceResult.splits || traces.map(({ word, found }) => ({ word, found })); + + return { word, traces, splits }; +} + +function simpleDocTrace(docVal: DocumentValidator, word: string): Api.TraceWordResult { const trace = docVal.traceWord(word); const byWord = groupByField(trace, 'word'); - const traces = [...byWord.entries()].map(([word, traces]) => ({ + const traces: Exclude = [...byWord.entries()].map(([word, traces]) => ({ word, found: isFound(traces), - traces: traces.map((t) => ({ ...t, errors: errorsToString(t.errors) })), + traces: traces.map((t) => ({ ...t, errors: errorsToString(t.errors), dictEnabled: true })), })); const splits = trace.splits || traces.map(({ word, found }) => ({ word, found })); @@ -24,6 +60,39 @@ export async function traceWord( return { word, traces, splits }; } +async function trace(word: string, settings: CSpellSettings, options?: TraceOptions) { + for await (const traceResult of traceWordsAsync([word], settings, options)) { + return traceResult; + } +} + +function extractDictionaryList(settings: CSpellSettings) { + const enabled = settings.dictionaries || []; + const dictionaries = (settings.dictionaryDefinitions || []).map((d) => d.name); + return { enabled, dictionaries }; +} + +export async function handleTraceRequest( + docValidationController: DocumentValidationController, + req: Api.TraceWordRequest, + getCachedDoc: (uri: string) => TextDocument | undefined, +): Promise { + const { word, uri, ...options } = req; + let doc = getCachedDoc(uri); + if (!doc) { + try { + const s = await stat(uri); + doc = s.isDirectory() + ? TextDocument.create(uri, req.languageId || 'plaintext', 0, '') + : await readTextDocument(uri, req.languageId); + } catch { + doc = undefined; + } + } + if (!doc) return { word, errors: 'Document Not Found.' }; + return traceWord(docValidationController, doc, word, options); +} + function errorsToString(errors: Error[] | undefined): string | undefined { if (!errors || !errors.length) return undefined; return errors.map((e) => e.message).join('\n'); diff --git a/packages/_server/src/utils/basename.mts b/packages/_server/src/utils/basename.mts new file mode 100644 index 000000000..6aba6732a --- /dev/null +++ b/packages/_server/src/utils/basename.mts @@ -0,0 +1,13 @@ +import type { URI } from 'vscode-uri'; + +import { toUrl } from './toUrl.mjs'; + +/** + * Get the basename of a URL + * @param url - the url to get the basename of + * @returns the basename of the url + */ +export function basename(url: string | URI | URL): string { + const u = toUrl(url); + return u.pathname.split('/').pop() || ''; +} diff --git a/packages/_server/src/utils/basename.test.mts b/packages/_server/src/utils/basename.test.mts new file mode 100644 index 000000000..2edf92ecf --- /dev/null +++ b/packages/_server/src/utils/basename.test.mts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest'; + +import { basename } from './basename.mjs'; + +describe('calcFileTypes', () => { + test.each` + url | expected + ${import.meta.url} | ${'basename.test.mts'} + ${'./file.js'} | ${'file.js'} + ${'./README.md'} | ${'README.md'} + ${'./dir/'} | ${''} + ${'./example.py'} | ${'example.py'} + `('basename $url', ({ url, expected }) => { + expect(basename(u(url))).toEqual(expected); + }); +}); + +function u(url: string | URL): string { + return new URL(url, import.meta.url).toString(); +} diff --git a/packages/_server/src/utils/calcFileTypes.mts b/packages/_server/src/utils/calcFileTypes.mts new file mode 100644 index 000000000..669e7c595 --- /dev/null +++ b/packages/_server/src/utils/calcFileTypes.mts @@ -0,0 +1,8 @@ +import { getLanguageIdsForBaseFilename } from 'cspell-lib'; +import type { URI } from 'vscode-uri'; + +import { basename } from './basename.mjs'; + +export function calcFileTypes(url: string | URI | URL): string[] { + return getLanguageIdsForBaseFilename(basename(url)); +} diff --git a/packages/_server/src/utils/calcFileTypes.test.mts b/packages/_server/src/utils/calcFileTypes.test.mts new file mode 100644 index 000000000..fcd397d75 --- /dev/null +++ b/packages/_server/src/utils/calcFileTypes.test.mts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest'; + +import { calcFileTypes } from './calcFileTypes.mjs'; + +describe('calcFileTypes', () => { + test.each` + url | expected + ${import.meta.url} | ${['typescript']} + ${'./file.js'} | ${['javascript']} + ${'./README.md'} | ${['markdown']} + ${'./dir/'} | ${[]} + ${'./example.py'} | ${['python']} + `('calcFileTypes $url', ({ url, expected }) => { + expect(calcFileTypes(u(url))).toEqual(expected); + }); +}); + +function u(url: string | URL): string { + return new URL(url, import.meta.url).toString(); +} diff --git a/packages/_server/src/utils/toUrl.mts b/packages/_server/src/utils/toUrl.mts new file mode 100644 index 000000000..0e39411ef --- /dev/null +++ b/packages/_server/src/utils/toUrl.mts @@ -0,0 +1,9 @@ +import type { URI } from 'vscode-uri'; + +export function toUrl(url: URL | URI | string): URL { + if (typeof url === 'string') { + return new URL(url); + } + if (url instanceof URL) return url; + return new URL(url.toString()); +} diff --git a/packages/_server/src/vfs/index.mts b/packages/_server/src/vfs/index.mts new file mode 100644 index 000000000..d26842fbf --- /dev/null +++ b/packages/_server/src/vfs/index.mts @@ -0,0 +1,2 @@ +export { readTextDocument } from './readTextDocument.mjs'; +export { isDir, isFile, readTextFile, stat } from './vfs.mjs'; diff --git a/packages/_server/src/vfs/readTextDocument.mts b/packages/_server/src/vfs/readTextDocument.mts new file mode 100644 index 000000000..880e992be --- /dev/null +++ b/packages/_server/src/vfs/readTextDocument.mts @@ -0,0 +1,16 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import type { URI } from 'vscode-uri'; + +import type { TextDocumentInfoWithText } from '../api.js'; +import { calcFileTypes } from '../utils/calcFileTypes.mjs'; +import { readTextFile } from './vfs.mjs'; + +export async function readTextDocument(url: string | URI | URL, filetype?: string): Promise { + const text = await readTextFile(url); + return toTextDocument({ uri: url.toString(), text: text, languageId: filetype }); +} + +export function toTextDocument(doc: TextDocumentInfoWithText): TextDocument { + const fileTypes = (!doc.languageId && calcFileTypes(doc.uri)) || []; + return TextDocument.create(doc.uri, doc.languageId || fileTypes[0] || 'plaintext', doc.version || 0, doc.text); +} diff --git a/packages/_server/src/vfs/readTextDocument.test.mts b/packages/_server/src/vfs/readTextDocument.test.mts new file mode 100644 index 000000000..e16ca73f8 --- /dev/null +++ b/packages/_server/src/vfs/readTextDocument.test.mts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'vitest'; + +import { readTextDocument } from './readTextDocument.mjs'; + +describe('readTextDocument', () => { + test('readTextDocument', async () => { + const doc = await readTextDocument(import.meta.url); + expect(doc.uri).toEqual(import.meta.url); + expect(doc.getText()).toContain("describe('readTextDocument'"); + expect(doc.languageId).toEqual('typescript'); + }); +}); diff --git a/packages/_server/src/vfs/vfs.mts b/packages/_server/src/vfs/vfs.mts new file mode 100644 index 000000000..ee40da8d7 --- /dev/null +++ b/packages/_server/src/vfs/vfs.mts @@ -0,0 +1,48 @@ +import type { VfsStat } from 'cspell-io'; +import { getVirtualFS } from 'cspell-lib'; +import type { URI } from 'vscode-uri'; + +import { toUrl } from '../utils/toUrl.mjs'; + +export async function stat(urlLike: URL | URI | string): Promise { + const url = toUrl(urlLike); + const vfs = getVirtualFS(); + const fs = vfs.getFS(url); + const stat = await fs.stat(url); + return stat; +} + +export async function readTextFile(url: URL | URI | string): Promise { + const _url = toUrl(url); + const vfs = getVirtualFS(); + const fs = vfs.getFS(_url); + const f = await fs.readFile(_url); + return f.getText(); +} + +export async function isFile(url: URL | URI | string): Promise { + try { + const statInfo = await stat(url); + return statInfo.isFile(); + } catch { + return false; + } +} + +export async function isDir(url: URL | URI | string): Promise { + try { + const statInfo = await stat(url); + return statInfo.isDirectory(); + } catch { + return false; + } +} + +export async function exists(url: URL | URI | string): Promise { + try { + await stat(url); + return true; + } catch { + return false; + } +} diff --git a/packages/client/jest.config.js b/packages/client/jest.config.js deleted file mode 100644 index 7078ba313..000000000 --- a/packages/client/jest.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - verbose: true, - roots: ['./src'], - transform: { - '^.+\\.tsx?$': 'ts-jest', - }, - testRegex: '\\.(test|spec|perf)\\.tsx?$', - testPathIgnorePatterns: ['/node_modules/'], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - moduleNameMapper: { - '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/src/__mocks__/fileMock.js', - '\\.(css|less)$': '/src/__mocks__/styleMock.js', - '^vscode$': '/src/__mocks__/vscode.js', - }, -}; - -// cspell:ignore webm diff --git a/packages/client/package.json b/packages/client/package.json index f3b151e87..fa4c97f09 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@internal/cspell-helper": "file:../__cspell-helper", "@internal/locale-resolver": "file:../__locale-resolver", + "@types/camelize": "^1.0.0", "@types/kefir": "^3.8.11", "@types/source-map-support": "^0.5.10", "cross-env": "^7.0.3", @@ -58,10 +59,15 @@ "@cspell/cspell-types": "^8.7.0", "@internal/common-utils": "file:../__utils", "@internal/settings-webview": "file:../_settingsViewer", + "ansi-escapes": "^6.2.0", + "ansi-styles": "^6.2.1", + "as-table": "^1.0.55", + "camelize": "^1.0.1", "code-spell-checker-server": "file:../_server", "comment-json": "^4.2.3", "fast-deep-equal": "^3.1.3", "kefir": "^3.8.8", + "optionator": "^0.9.3", "utils-disposables": "file:../utils-disposables", "utils-logger": "file:../utils-logger", "vscode-jsonrpc": "^8.2.0", @@ -69,7 +75,8 @@ "vscode-uri": "^3.0.8", "vscode-webview-rpc": "file:../webview-rpc", "webview-api": "file:../webview-api", - "yaml": "^2.4.1" + "yaml": "^2.4.1", + "yargs": "^17.7.2" }, "engines": { "node": ">18.0.0" diff --git a/packages/client/src/__mocks__/vscode.js b/packages/client/src/__mocks__/vscode.js index d4ae16b44..49c8b281c 100644 --- a/packages/client/src/__mocks__/vscode.js +++ b/packages/client/src/__mocks__/vscode.js @@ -1,2 +1,3 @@ /* eslint-disable node/no-extraneous-require */ +/* eslint-disable @typescript-eslint/no-var-requires */ module.exports = require('jest-mock-vscode').createVSCodeMock(jest); diff --git a/packages/client/src/client/server/server.ts b/packages/client/src/client/server/server.ts index a0a91ea68..a27570329 100644 --- a/packages/client/src/client/server/server.ts +++ b/packages/client/src/client/server/server.ts @@ -9,6 +9,7 @@ import { CodeActionRequest } from 'vscode-languageclient/node'; import { vfsReadDirectory, vfsReadFile, vfsStat } from './vfs'; export type { + CheckDocumentIssue, ClientSideCommandHandlerApi, ConfigKind, ConfigScope, @@ -48,6 +49,7 @@ interface ServerSide { registerConfigurationFile: ClientSideApi['serverNotification']['registerConfigurationFile']; spellingSuggestions: ClientSideApi['serverRequest']['spellingSuggestions']; traceWord: ClientSideApi['serverRequest']['traceWord']; + checkDocument: ClientSideApi['serverRequest']['checkDocument']; } interface ExtensionSide { @@ -78,6 +80,7 @@ export function createServerApi(client: LanguageClient): ServerApi { spellingSuggestions: true, splitTextIntoWords: true, traceWord: true, + checkDocument: true, }, serverNotifications: { notifyConfigChange: true, @@ -107,6 +110,7 @@ export function createServerApi(client: LanguageClient): ServerApi { notifyConfigChange: log2Sfn(serverNotification.notifyConfigChange, 'notifyConfigChange'), registerConfigurationFile: log2Sfn(serverNotification.registerConfigurationFile, 'registerConfigurationFile'), traceWord: log2Sfn(serverRequest.traceWord, 'traceWord'), + checkDocument: log2Sfn(serverRequest.checkDocument, 'checkDocument'), onSpellCheckDocument: (fn) => clientNotification.onSpellCheckDocument.subscribe(log2Cfn(fn, 'onSpellCheckDocument')), onDiagnostics: (fn) => clientNotification.onDiagnostics.subscribe(log2Cfn(fn, 'onDiagnostics')), onWorkspaceConfigForDocumentRequest: (fn) => diff --git a/packages/client/src/commands.ts b/packages/client/src/commands.ts index dd397d05e..c3783c7d5 100644 --- a/packages/client/src/commands.ts +++ b/packages/client/src/commands.ts @@ -181,6 +181,7 @@ export const commandHandlers = { 'cSpell.toggleVisible': handlerResolvedLater, 'cSpell.show': handlerResolvedLater, 'cSpell.hide': handlerResolvedLater, + 'cSpell.createCSpellTerminal': handlerResolvedLater, } as const satisfies CommandHandler; type ImplementedCommandHandlers = typeof commandHandlers; diff --git a/packages/client/src/extension.ts b/packages/client/src/extension.ts index 9f8518a51..79f05c0cc 100644 --- a/packages/client/src/extension.ts +++ b/packages/client/src/extension.ts @@ -19,6 +19,7 @@ import * as settingsViewer from './infoViewer/infoView'; import { IssueTracker } from './issueTracker'; import { activateFileIssuesViewer, activateIssueViewer } from './issueViewer'; import * as modules from './modules'; +import { createTerminal } from './repl/index.js'; import type { ConfigTargetLegacy, CSpellSettings } from './settings'; import * as settings from './settings'; import { sectionCSpell } from './settings'; @@ -86,6 +87,7 @@ export async function activate(context: ExtensionContext): Promise 'cSpell.toggleVisible': () => decorator.toggleVisible(), 'cSpell.show': () => (decorator.visible = true), 'cSpell.hide': () => (decorator.visible = false), + 'cSpell.createCSpellTerminal': createTerminal, }; // Push the disposable to the context's subscriptions so that the diff --git a/packages/client/src/repl/@types/optionator.d.ts b/packages/client/src/repl/@types/optionator.d.ts new file mode 100644 index 000000000..05f43d5eb --- /dev/null +++ b/packages/client/src/repl/@types/optionator.d.ts @@ -0,0 +1,61 @@ +// See https://github.com/gkz/optionator#settings-format +declare module 'optionator' { + + // eslint-disable-next-line + module optionator { + + interface OptionatorHeading { + heading: string; + } + + interface OptionatorOption { + option: string; + alias?: string | string[]; + type: string; + enum?: string[]; + default?: string; + restPositional?: boolean; + required?: boolean; + overrideRequired?: boolean; + dependsOn?: string | string[]; + concatRepeatedArrays?: boolean | [boolean, object]; + mergeRepeatedObjects?: boolean; + description?: string; + longDescription?: string; + example?: string | string[]; + } + + interface OptionatorHelpStyle { + aliasSeparator?: string; + typeSeparator?: string; + descriptionSeparator?: string; + initialIndent?: number; + secondaryIndent?: number; + maxPadFactor?: number; + } + + interface OptionatorArgs { + prepend?: string; + append?: string; + options: (OptionatorHeading | OptionatorOption)[]; + helpStyle?: OptionatorHelpStyle; + mutuallyExclusive?: (string | string[])[]; + positionalAnywhere?: boolean; + typeAliases?: object; + defaults?: Partial; + stdout?: NodeJS.WritableStream; + } + + interface Optionator { + parse(input: string | string[] | object, parseOptions?: { slice?: number }): any; + parseArgv(input: string[]): any; + generateHelp(helpOptions?: { showHidden?: boolean; interpolate?: any }): string; + generateHelpForOption(optionName: string): string; + } + } + + function optionator(args: optionator.OptionatorArgs): optionator.Optionator; + export = optionator; +} + +// cspell:word optionator diff --git a/packages/client/src/repl/ansiUtils.mts b/packages/client/src/repl/ansiUtils.mts new file mode 100644 index 000000000..711f7fbb1 --- /dev/null +++ b/packages/client/src/repl/ansiUtils.mts @@ -0,0 +1,65 @@ +import type { Direction } from 'node:tty'; + +import ansiEscapes from 'ansi-escapes'; +import type { ColorName, ModifierName } from 'ansi-styles'; +import styles, { colorNames, modifierNames } from 'ansi-styles'; + +const colorFns = [...colorNames, ...modifierNames].map((name) => [name, (text: string) => styles[name].open + text + styles[name].close]); + +type ColorMethods = Record string>; + +export const colors: ColorMethods = Object.fromEntries(colorFns) as ColorMethods; + +export function green(text: string): string { + return styles.green.open + text + styles.green.close; +} + +export function red(text: string): string { + return styles.red.open + text + styles.red.close; +} + +export function yellow(text: string): string { + return styles.yellow.open + text + styles.yellow.close; +} + +export function crlf(text: string): string { + return text.replace(/\n/g, '\r\n').replace(/\r+\r/g, '\r'); +} + +export function dim(text: string): string { + return styles.dim.open + text + styles.dim.close; +} + +export type ColorFn = (text: string) => string; + +export function combine(fn: ColorFn, ...fns: ColorFn[]): ColorFn { + return (text) => fns.reduce((acc, f) => f(acc), fn(text)); +} + +export function clearScreen() { + return ansiEscapes.clearScreen; +} + +export function clearLine(dir: Direction) { + return dir > 0 ? ansiEscapes.eraseEndLine : dir < 0 ? ansiEscapes.eraseStartLine : ansiEscapes.eraseLine; +} + +export function clearDown() { + return ansiEscapes.eraseDown; +} + +export function moveCursor(dx: number, dy?: number | undefined) { + return ansiEscapes.cursorMove(dx, dy); +} + +export function cursorTo(x: number, y?: number | undefined) { + return ansiEscapes.cursorTo(x, y); +} + +export function eraseLines(n: number) { + return ansiEscapes.eraseLines(n); +} + +export function eraseLine() { + return ansiEscapes.eraseLine + '\r'; +} diff --git a/packages/client/src/repl/args.mts b/packages/client/src/repl/args.mts new file mode 100644 index 000000000..025f2480d --- /dev/null +++ b/packages/client/src/repl/args.mts @@ -0,0 +1,550 @@ +import type { ParseArgsConfig } from 'node:util'; +import { parseArgs } from 'node:util'; + +import assert from 'assert'; + +import { splitIntoLines } from './textUtils.mjs'; + +type NodeParsedResults = ReturnType; +type ParsedToken = Exclude[number]; + +const defaultWidth = 80; + +export class Command { + #arguments: Argument[] = []; + #options: Option[] = []; + #handler?: HandlerFn; + constructor( + readonly name: string, + readonly description: string, + args: ArgDefs, + options: OptDefs, + handler?: HandlerFn, + ) { + for (const [key, def] of Object.entries(args)) { + this.#arguments.push(new Argument(key, def)); + } + for (const [key, def] of Object.entries(options)) { + this.#options.push(new Option(key, def)); + } + const found = this.#options.find((o) => o.name == 'help'); + !found && this.#options.push(new Option('help', { type: 'boolean', description: 'Show help', short: 'h' })); + this.#handler = handler; + } + + handler(fn: HandlerFn) { + this.#handler = fn; + return this; + } + + handles(argv: string[]): boolean { + return argv[0] == this.name; + } + + parse(argv: string[]): ParsedResults { + const tokenizer = createTokenizer(this); + assert(argv[0] == this.name, `Command name mismatch: ${argv[0]} != ${this.name}`); + const tokens = tokenizer(argv.slice(1)); + + // console.log('tokens: %o', tokens); + + const positionals: string[] = []; + const args = { _: positionals } as ArgDefsToArgs; + const shadowArgs: Record = args; + const options = {} as OptDefsToOpts; + const shadowOpts: Record = options; + + const argDefs = this.arguments; + let i = 0; + for (const token of tokens) { + switch (token.kind) { + case 'option': + { + let invert = false; + let opt = this.#options.find((o) => o.name == token.name); + if (!opt) { + if (token.name.startsWith('no-')) { + opt = this.#options.find((o) => o.name == token.name.slice(3)); + invert = true; + } + } + if (!opt) { + throw new Error(`Unknown option: ${token.name}`); + } + const name = opt.name; + let value = castValueToType(token.value, opt.baseType); + if (invert && typeof value == 'boolean') { + value = !value; + } + shadowOpts[name] = opt.multiple ? append(options[name], value) : value; + } + break; + case 'positional': + { + positionals.push(token.value); + if (i >= argDefs.length) { + throw new Error(`Unexpected argument: ${token.value}`); + } + const arg = argDefs[i]; + const value = token.value; + shadowArgs[arg.name] = arg.multiple ? append(args[arg.name], value) : value; + i += arg.multiple ? 0 : 1; + } + break; + case 'option-terminator': + break; + } + } + + return { args, options, argv }; + } + + async exec(argv: string[]) { + assert(this.#handler, 'handler not set'); + assert(argv[0] == this.name); + const parsedArgs = this.parse(argv); + await this.#handler(parsedArgs); + } + + getArgString() { + return this.#arguments.join(' '); + } + + get options(): Option[] { + return this.#options; + } + + get arguments(): Argument[] { + return this.#arguments; + } +} + +export class Application { + #commands = new Map(); + public displayWidth = defaultWidth; + + constructor( + public name: string, + public description: string = '', + public usage: string = '', + ) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addCommand(...commands: Command[]) { + for (const cmd of commands) { + this.#commands.set(cmd.name, cmd); + } + return this; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addCommands(commands: Command[]) { + return this.addCommand(...commands); + } + + getHelp(command?: string, width?: number): string { + const cmd = command ? this.#commands.get(command) : undefined; + if (command && !cmd) throw new Error(`Unknown command: ${command}`); + width = width || this.displayWidth; + return cmd ? this.#formatCommandHelp(cmd, width) : this.#formatHelp(width); + } + + getApplicationHeader(width = this.displayWidth) { + return this.#formatApplicationHeader(width); + } + + #formatHelp(width: number): string { + const lines = []; + lines.push(this.#formatApplicationHeader(width)); + const commands = [...this.#commands.values()].sort((a, b) => a.name.localeCompare(b.name)); + const cmdPrefix = ' '; + const cols = commands + .map((cmd) => commandHelpLine(cmd)) + .map((line) => [cmdPrefix + line.cmd + ' ' + line.args, line.description.trim()] as const); + lines.push('Commands:'); + lines.push(formatTwoColumns(cols, width, ' ')); + return lines.join('\n'); + } + + #formatCommandHelp(cmd: Command, width: number): string { + const lines = []; + const options = cmd.options.length ? ' [options]' : ''; + lines.push(`Usage: ${cmd.name}${options} ${cmd.getArgString()}`); + lines.push('', ...splitIntoLines(cmd.description, width)); + const indent = ' '; + const argumentColumns = cmd.arguments.map((arg) => [indent + arg, arg.description.trim()] as const); + if (argumentColumns.length) { + lines.push(''); + lines.push('Arguments:'); + lines.push(formatTwoColumns(argumentColumns, width, ' ')); + } + const optionColumns = cmd.options.map((opt) => [indent + opt, opt.description.trim()] as const); + if (optionColumns.length) { + lines.push(''); + lines.push('Options:'); + lines.push(formatTwoColumns(optionColumns, width, ' ')); + } + return lines.join('\n'); + } + + #formatApplicationHeader(width: number) { + const lines = []; + lines.push(this.name, ''); + if (this.description) { + lines.push(...splitIntoLines(this.description, width), ''); + } + if (this.usage) { + lines.push('Usage:', ...splitIntoLines(this.usage, width), ''); + } + return lines.join('\n'); + } + + getCommand(cmdName: string): Command | undefined { + return this.#commands.get(cmdName); + } + + parseArgs(args: string[]) { + const cmdName = args[0]; + const cmd = this.getCommand(cmdName); + if (!cmd) { + throw new Error(`Unknown command: ${cmdName}`); + } + return cmd.parse(args); + } + + async exec(argv: string[], log: typeof console.log) { + const cmdName = argv[0]; + const cmd = this.getCommand(cmdName); + if (!cmd) { + throw new Error(`Unknown command: ${cmdName}`); + } + const parsedArgs = cmd.parse(argv); + if (parsedArgs.options.help) { + log(this.#formatCommandHelp(cmd, this.displayWidth)); + return; + } + await cmd.exec(argv); + } + + getCommandNames() { + return [...this.#commands.keys()]; + } +} + +function commandHelpLine(cmd: Command) { + const argLine = cmd.getArgString(); + return { cmd: cmd.name, args: argLine, description: cmd.description }; +} + +class Argument implements Required> { + readonly multiple: boolean; + readonly description: string; + readonly type: V; + readonly required: boolean; + + constructor( + readonly name: K, + def: ArgDef, + ) { + this.description = def.description; + this.type = def.type; + this.required = def.required || false; + this.multiple = this.type.endsWith('[]'); + } + toString() { + const variadic = this.multiple ? '...' : ''; + const name = `${this.name}${variadic}`; + return this.required ? `<${name}>` : `[${name}]`; + } +} + +class Option implements Required> { + readonly multiple: boolean; + readonly description: string; + readonly type: V; + readonly short: string; + readonly param: string; + readonly required: boolean; + readonly baseType: OptionTypeBaseNames; + readonly variadic: boolean; + + constructor( + readonly name: K, + def: OptionDef, + ) { + this.description = def.description; + this.type = def.type; + this.short = def.short || ''; + this.param = def.param || this.name; + this.required = def.required ?? true; + this.baseType = typeNameToBaseTypeName(this.type); + this.multiple = this.type.endsWith('[]'); + this.variadic = def.variadic || false; + + if (this.variadic && !this.multiple) { + throw new Error('variadic option must be multiple'); + } + } + + toString() { + const short = this.short ? `-${this.short}, ` : ''; + const paramName = this.param || this.name; + const suffix = this.variadic ? '...' : ''; + + const paramWithSuffix = ['boolean', 'boolean[]'].includes(this.type) ? '' : `${paramName}${suffix}`; + const param = paramWithSuffix && (this.required ? ` <${paramWithSuffix}>` : ` [${paramWithSuffix}]`); + + return `${short}--${this.name}${param}`; + } +} + +interface OptionDef { + /** + * The description of the option + */ + readonly description: string; + /** + * The type of the option parameter + */ + readonly type: T; + /** + * The short name for the option + * Must be a single character. + */ + readonly short?: string | undefined; + /** + * The name of the parameter for the option + * + * Defaults to the option name + */ + readonly param?: string | undefined; + /** + * Indicates if the option parameter is optional or required + * @default true + */ + readonly required?: boolean | undefined; + + readonly variadic?: boolean | undefined; +} + +interface ArgDef { + /** + * The description of the option + */ + readonly description: string; + /** + * The type of the option parameter + */ + readonly type: T; + /** + * Indicates if the option parameter is optional or required + * + * Note: this only impacts non-boolean values. + * @default false + */ + readonly required?: boolean | undefined; +} + +interface OptionTypeDefBase { + boolean: boolean; + string: string; + number: number; +} + +interface OptionTypeDefs extends OptionTypeDefBase { + 'boolean[]': boolean[]; + 'string[]': string[]; + 'number[]': number[]; +} + +export type OptionTypeBaseNames = keyof OptionTypeDefBase; +export type OptionTypeNames = keyof OptionTypeDefs; + +type ArgTypeDefs = Pick; + +export type ArgTypeNames = keyof ArgTypeDefs; +export type ArgTypes = ArgTypeDefs[ArgTypeNames]; + +export type TypeNameToType = T extends 'boolean' + ? boolean + : T extends 'string' + ? string + : T extends 'number' + ? number + : T extends 'boolean[]' + ? boolean[] + : T extends 'string[]' + ? string[] + : T extends 'number[]' + ? number[] + : never; + +export type DefToType> = { + [K in keyof T]: TypeNameToType; +}; + +type SpecificOptionTypes = OptionTypeDefs[K]; +type OptionTypes = SpecificOptionTypes; + +export type TypeToTypeName = T extends boolean + ? 'boolean' + : T extends string + ? 'string' + : T extends number + ? 'number' + : T extends boolean[] + ? 'boolean[]' + : T extends string[] + ? 'string[]' + : T extends number[] + ? 'number[]' + : never; + +export type ArgsDefinitions = { + [k in string]: ArgDef; +}; + +type ArgInlineDef = { [k in N]: ArgDef }; + +/** + * Define an argument + * @param name - The name of the argument + * @param type - The type of the argument `string` | `string[]` + * @param description - The description of the argument + * @param required - Indicates if the argument is required or optional + * @returns An object that can be used to define arguments inline + */ +export function defArg( + name: N, + type: T, + description: string, + required?: boolean, +): ArgInlineDef { + return { [name]: { type, description, required } } as ArgInlineDef; +} + +type OptInlineDef = { [k in N]: OptionDef }; + +/** + * Define an option + * @param name - The name of the argument + * @param type - The type of the argument `string` | `string[]` + * @param description - The description of the argument + * @param required - Indicates if the argument is required or optional + * @returns An object that can be used to define arguments inline + */ +export function defOpt( + name: N, + type: T, + description: string, + short: string | undefined, +): OptInlineDef { + return { [name]: { type, description, short: short ? short : undefined } } as OptInlineDef; +} + +type ArgDefsToArgs = { + [k in keyof T]?: TypeNameToType; +} & { _: string[] }; + +export type OptionDefinitions = { + [k in string]: OptionDef; +}; + +type OptDefsToOpts = { + [k in keyof T]: TypeNameToType; +}; + +type ParsedResults = { + args: ArgDefsToArgs; + options: OptDefsToOpts; + argv: string[]; +}; + +type HandlerFn = ( + parsedArgs: ParsedResults, +) => Promise | void; + +function formatTwoColumns(columns: readonly (readonly [string, string])[], width: number, sep = ' ') { + const lines = []; + + const col1Width = Math.max(...columns.map(([left]) => left.length), 0); + + for (const [left, right] of columns) { + const rightLines = splitIntoLines(right, width - col1Width - sep.length); + lines.push(`${left.padEnd(col1Width)}${sep}${rightLines[0] || ''}`); + for (const line of rightLines.slice(1)) { + lines.push(' '.repeat(col1Width) + sep + line); + } + } + return lines.join('\n'); +} + +function typeNameToBaseTypeName(type: 'boolean'): 'boolean'; +function typeNameToBaseTypeName(type: 'number'): 'number'; +function typeNameToBaseTypeName(type: 'string'): 'string'; +function typeNameToBaseTypeName(type: 'boolean[]'): 'boolean'; +function typeNameToBaseTypeName(type: 'number[]'): 'number'; +function typeNameToBaseTypeName(type: 'string[]'): 'string'; +function typeNameToBaseTypeName(type: OptionTypeNames): OptionTypeBaseNames; +function typeNameToBaseTypeName(type: OptionTypeNames): OptionTypeBaseNames { + return type.replace('[]', '') as OptionTypeBaseNames; +} + +function append(...values: (T | T[] | undefined)[]): T[] { + return values.flatMap((a) => a).filter((v): v is T => v !== undefined); +} + +function createTokenizer( + command: Command, +): (args: string[]) => ParsedToken[] { + const options: ParseArgsConfig['options'] = {}; + + for (const opt of command.options) { + options[opt.name] = { + type: opt.baseType !== 'boolean' ? 'string' : 'boolean', + multiple: opt.multiple, + }; + if (opt.short) { + options[opt.name].short = opt.short; + } + } + + return (args: string[]) => { + const result = parseArgs({ args, options, allowPositionals: true, tokens: true, strict: false }); + const tokens = result.tokens || []; + return tokens; + }; +} + +export function castValueToType(value: unknown, type: T): SpecificOptionTypes; +export function castValueToType(value: unknown, type: OptionTypeBaseNames): SpecificOptionTypes; +export function castValueToType(value: unknown, type: OptionTypeBaseNames): SpecificOptionTypes { + switch (type) { + case 'boolean': + return toBoolean(value ?? true); + case 'number': + return toNumber(value); + case 'string': + return typeof value == 'string' ? value : `${value}`; + } +} + +export function toBoolean(value: unknown): boolean; +export function toBoolean(value: unknown | undefined): boolean | undefined; +export function toBoolean(value: unknown | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value == 'boolean') return value; + if (typeof value == 'number') return !!value; + if (typeof value == 'string') { + const v = value.toLowerCase().trim(); + if (['true', 't', 'yes', 'y', '1', 'ok'].includes(v)) return true; + if (['false', 'f', 'no', 'n', '0', ''].includes(v)) return false; + } + throw new Error(`Invalid boolean value: ${value}`); +} + +export function toNumber(value: unknown): number { + const num = Number(value); + if (!Number.isNaN(num)) return num; + throw new Error(`Invalid number value: ${value}`); +} diff --git a/packages/client/src/repl/args.test.mts b/packages/client/src/repl/args.test.mts new file mode 100644 index 000000000..296efb00f --- /dev/null +++ b/packages/client/src/repl/args.test.mts @@ -0,0 +1,294 @@ +import assert from 'node:assert'; +import type { ParseArgsConfig } from 'node:util'; +import { parseArgs } from 'node:util'; + +import { describe, expect, test } from 'vitest'; + +import { Application, castValueToType, Command, toBoolean } from './args.mjs'; +import { parseCommandLineIntoArgs } from './parseCommandLine.js'; +import { unindent } from './textUtils.mjs'; + +const ac = expect.arrayContaining; + +const tokens = ac([]); + +const T = true; + +const r = unindent; + +/* + * Test our parseArgs assumptions. + */ +describe('parseArgs', () => { + test.each` + args | expected + ${['-abc', 'hello', 'there']} | ${{ positionals: ['hello', 'there'], values: { apple: true, banana: true, cherry: true }, tokens }} + ${['--fruit', 'apple', 'banana', '--', 'hello', 'there']} | ${{ positionals: ['banana', 'hello', 'there'], values: { fruit: ['apple'] }, tokens }} + ${['-f', 'apple', '--fruit=banana', '--', 'hello', 'there']} | ${{ positionals: ['hello', 'there'], values: { fruit: ['apple', 'banana'] }, tokens }} + ${['-f=apple', '--fruit=banana', '--']} | ${{ positionals: [], values: { fruit: ['=apple', 'banana'] }, tokens }} + ${['-abC7', 'hello', 'there']} | ${{ positionals: ['hello', 'there'], values: { apple: true, banana: true, code: '7' }, tokens }} + ${['-C7', '-C8']} | ${{ positionals: [], values: { code: '8' }, tokens }} + ${['-a', 'red', '-C', '8', '-vvv', '--verbose']} | ${{ positionals: ['red'], values: { apple: true, code: '8', verbose: [T, T, T, T] }, tokens }} + `('pareArgs $args', ({ args, expected }) => { + const options: ParseArgsConfig['options'] = { + apple: { type: 'boolean', short: 'a' }, + banana: { type: 'boolean', short: 'b' }, + cherry: { type: 'boolean', short: 'c' }, + code: { type: 'string', short: 'C' }, + verbose: { type: 'boolean', short: 'v', multiple: true }, + fruit: { type: 'string', short: 'f', multiple: true }, + }; + const result = parseArgs({ args, options, allowPositionals: true, tokens: true }); + // console.log('%o', result); + expect(result).toEqual(expected); + }); + + // cspell:ignore alse + test.each` + args | expected + ${['-a', 'red', '-C', '8', '-vvv', '--verbose', '--verbose=false']} | ${{ positionals: ['red'], values: { apple: true, code: '8', verbose: [T, T, T, T, 'false'] }, tokens }} + ${['-a', 'red', '-C', '8', '-vvv', '--verbose', '-v=false']} | ${{ positionals: ['red'], values: { apple: true, code: '8', fruit: ['alse'], verbose: [T, T, T, T, T], '=': T }, tokens }} + `('pareArgs $args', ({ args, expected }) => { + const options: ParseArgsConfig['options'] = { + apple: { type: 'boolean', short: 'a' }, + banana: { type: 'boolean', short: 'b' }, + cherry: { type: 'boolean', short: 'c' }, + code: { type: 'string', short: 'C' }, + verbose: { type: 'boolean', short: 'v', multiple: true }, + fruit: { type: 'string', short: 'f', multiple: true }, + }; + const result = parseArgs({ args, options, allowPositionals: true, tokens: true, strict: false }); + // console.log('%o', result); + expect(result).toEqual(expected); + }); +}); + +describe('Application', () => { + const anyArgs = ac([]); + const cmdFoo = new Command( + 'foo', + 'Display some foo.', + { + count: { type: 'string', required: true, description: 'Amount of foo to display' }, + names: { type: 'string[]', description: 'Optional names to display.' }, + }, + { + verbose: { type: 'boolean', short: 'v', description: 'Show extra details' }, + upper: { type: 'boolean', short: 'u', description: 'Show in uppercase' }, + repeat: { type: 'number', short: 'r', description: 'Repeat the message' }, + }, + ); + + const cmdBar = new Command( + 'bar', + 'Make blocking statements.', + { + message: { type: 'string', required: true, description: 'The message to display' }, + }, + { + loud: { type: 'boolean', short: 'l', description: 'Make it loud' }, + }, + ); + + const cmdHelp = new Command( + 'help', + 'Display Help', + { + command: { type: 'string', description: 'Show Help for command.' }, + }, + {}, + ); + + const cmdComplex = new Command( + 'complex', + r(`\ + This is a command with unnecessary complexity and options. + Even the description is long and verbose. with a lot of words and new lines. + - one: Argument one. + - two: Argument two. + `), + { + one: { type: 'string', required: true, description: 'Argument one.' }, + two: { type: 'string', required: true, description: 'Argument two.' }, + many: { type: 'string[]', description: 'The rest of the arguments.' }, + }, + { + verbose: { type: 'boolean[]', short: 'v', description: 'Show extra details' }, + upper: { type: 'boolean', short: 'u', description: 'Show in uppercase' }, + lower: { type: 'boolean', short: 'l', description: 'Show in lowercase' }, + 'pad-left': { type: 'number', description: 'Pad the left side' }, + 'pad-right': { type: 'number', description: 'Pad the right side' }, + }, + ); + + test('Application Help', () => { + const commands = [cmdFoo, cmdBar, cmdComplex, cmdHelp]; + const app = new Application('test', 'Test Application.').addCommands(commands); + expect(app.getHelp()).toBe( + r(`\ + test + + Test Application. + + Commands: + bar Make blocking statements. + complex [many...] This is a command with unnecessary complexity + and options. + Even the description is long and verbose. with + a lot of words and new lines. + - one: Argument one. + - two: Argument two. + foo [names...] Display some foo. + help [command] Display Help`), + ); + }); + + test.each` + cmd | expected + ${'bar hello --loud'} | ${{ argv: anyArgs, args: { _: ['hello'], message: 'hello' }, options: { loud: true } }} + ${'bar -l none'} | ${{ argv: anyArgs, args: { _: ['none'], message: 'none' }, options: { loud: true } }} + ${'foo 5 one two -r 2'} | ${{ argv: anyArgs, args: { _: ['5', 'one', 'two'], count: '5', names: ['one', 'two'] }, options: { repeat: 2 } }} + ${'foo 5 one two -r 2 -r7'} | ${{ argv: anyArgs, args: { _: ['5', 'one', 'two'], count: '5', names: ['one', 'two'] }, options: { repeat: 7 } }} + ${'foo 42 --repeat=7'} | ${{ argv: anyArgs, args: { _: ['42'], count: '42' }, options: { repeat: 7 } }} + ${'complex a b c d -v -v -v -v'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: [T, T, T, T] } }} + ${'complex a b c d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: {} }} + ${'complex a b c --verbose=false d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: [false] } }} + ${'complex a b c --no-verbose d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: [false] } }} + ${'complex a b c --no-verbose=true d'} | ${{ argv: anyArgs, args: { _: [...'abcd'], one: 'a', two: 'b', many: ['c', 'd'] }, options: { verbose: [false] } }} + `('Parse Command $cmd', ({ cmd: commandLine, expected }) => { + const commands = [cmdFoo, cmdBar, cmdComplex, cmdHelp]; + const app = new Application('test', 'Test Application.').addCommands(commands); + const argv = parseCommandLineIntoArgs(commandLine); + const command = app.getCommand(argv[0]); + assert(command); + const args = command.parse(argv); + expect(args).toEqual(expected); + }); + + test('Command Help', () => { + const commands = [cmdFoo, cmdBar, cmdComplex, cmdHelp]; + const app = new Application('test', 'Test Application.').addCommands(commands); + + expect(app.getHelp('foo')).toBe( + r(`\ + Usage: foo [options] [names...] + + Display some foo. + + Arguments: + Amount of foo to display + [names...] Optional names to display. + + Options: + -v, --verbose Show extra details + -u, --upper Show in uppercase + -r, --repeat Repeat the message + -h, --help Show help`), + ); + + expect(app.getHelp('bar')).toBe( + r(`\ + Usage: bar [options] + + Make blocking statements. + + Arguments: + The message to display + + Options: + -l, --loud Make it loud + -h, --help Show help`), + ); + + expect(app.getHelp('help')).toBe( + r(`\ + Usage: help [options] [command] + + Display Help + + Arguments: + [command] Show Help for command. + + Options: + -h, --help Show help`), + ); + + expect(app.getHelp('complex')).toBe( + r(`\ + Usage: complex [options] [many...] + + This is a command with unnecessary complexity and options. + Even the description is long and verbose. with a lot of words and new lines. + - one: Argument one. + - two: Argument two. + + + Arguments: + Argument one. + Argument two. + [many...] The rest of the arguments. + + Options: + -v, --verbose Show extra details + -u, --upper Show in uppercase + -l, --lower Show in lowercase + --pad-left Pad the left side + --pad-right Pad the right side + -h, --help Show help`), + ); + }); +}); + +describe('conversions', () => { + test.each` + value | expected + ${true} | ${true} + ${false} | ${false} + ${1} | ${true} + ${0} | ${false} + ${NaN} | ${false} + ${'True'} | ${true} + ${'False'} | ${false} + ${'T'} | ${true} + ${'F'} | ${false} + ${'1'} | ${true} + ${'0'} | ${false} + ${'yes'} | ${true} + ${'no'} | ${false} + ${undefined} | ${undefined} + `('toBoolean $value', ({ value, expected }) => { + expect(toBoolean(value)).toBe(expected); + }); + + test.each` + value | expected + ${'sunny'} | ${'Invalid boolean value: sunny'} + `('toBoolean $value with error', ({ value, expected }) => { + expect(() => toBoolean(value)).toThrow(expected); + }); + + test.each` + value | optType | expected + ${true} | ${'boolean'} | ${true} + ${true} | ${'string'} | ${'true'} + ${42} | ${'string'} | ${'42'} + ${{ a: 'b' }} | ${'string'} | ${'[object Object]'} + ${NaN} | ${'string'} | ${'NaN'} + ${true} | ${'number'} | ${1} + ${42} | ${'number'} | ${42} + ${'42'} | ${'number'} | ${42} + ${'0x10'} | ${'number'} | ${16} + ${'010'} | ${'number'} | ${10} + `('castValueToType $value $optType', ({ value, optType, expected }) => { + expect(castValueToType(value, optType)).toBe(expected); + }); + + test.each` + value | optType | expected + ${'42b'} | ${'number'} | ${'Invalid number value: 42b'} + ${'42b'} | ${'boolean'} | ${'Invalid boolean value: 42b'} + ${{}} | ${'boolean'} | ${'Invalid boolean value: [object Object]'} + `('castValueToType $value $optType to error', ({ value, optType, expected }) => { + expect(() => castValueToType(value, optType)).toThrow(expected); + }); +}); diff --git a/packages/client/src/repl/asyncQueue.mts b/packages/client/src/repl/asyncQueue.mts new file mode 100644 index 000000000..31abf11d4 --- /dev/null +++ b/packages/client/src/repl/asyncQueue.mts @@ -0,0 +1,31 @@ +export async function* asyncQueue(fnValues: Iterable<() => T | Promise>, maxQueue = 10): AsyncIterable { + function* buffered() { + let done = false; + const buffer: Promise[] = []; + const iter = fnValues[Symbol.iterator](); + + function fill() { + while (buffer.length < maxQueue) { + const next = iter.next(); + done = !!next.done; + if (done) return; + if (next.done) return; + buffer.push(Promise.resolve(next.value())); + } + } + + fill(); + + while (!done && buffer.length) { + yield buffer[0]; + buffer.shift(); + fill(); + } + + yield* buffer; + } + + for await (const value of buffered()) { + yield value; + } +} diff --git a/packages/client/src/repl/cmdCheck.mts b/packages/client/src/repl/cmdCheck.mts new file mode 100644 index 000000000..11599fbf3 --- /dev/null +++ b/packages/client/src/repl/cmdCheck.mts @@ -0,0 +1,88 @@ +import type * as API from 'code-spell-checker-server/api'; +import type { CancellationToken, Uri } from 'vscode'; + +import * as di from '../di.js'; +import { colors } from './ansiUtils.mjs'; +import { formatPath, relative } from './formatPath.mjs'; + +const maxPathLen = 60; + +export async function cmdCheckDocument(uri: Uri | string, options: CheckDocumentsOptions, index?: number, count?: number): Promise { + const client = di.get('client'); + const { forceCheck, output, log, width } = options; + + const startTs = performance.now(); + const prefix = countPrefix(index, count); + + output(`${prefix}${colors.gray(formatPath(relative(uri), Math.min(maxPathLen, width - 10 - prefix.length)))}`); + const result = await client.serverApi.checkDocument({ uri: uri.toString() }, { forceCheck }); + + const elapsed = performance.now() - startTs; + const elapsedTime = elapsed.toFixed(2) + 'ms'; + + if (result.skipped) { + log(` ${elapsedTime} S`); + return; + } + + const lines: string[] = []; + + const issues = result.issues?.filter((issue) => !issue.isSuggestion); + + const failed = !!(result.errors || issues?.length); + + lines.push(` ${elapsedTime}${failed ? colors.red(' X') : ''}`); + + if (result.errors) { + lines.push(`${colors.red('Errors: ')} ${result.errors}`); + } + + if (issues) { + for (const issue of issues) { + lines.push(formatIssue(uri, issue)); + } + } + + log('%s', lines.join('\n')); +} + +function countPrefix(index?: number, count?: number): string { + if (typeof index === 'undefined' || typeof count === 'undefined') return ''; + const countStr = count.toString(); + const indexStr = (index + 1).toString().padStart(countStr.length, ' '); + return `${indexStr}/${countStr} `; +} + +function formatIssue(uri: string | Uri, issue: API.CheckDocumentIssue): string { + const { range, text, suggestions, isFlagged } = issue; + const pos = `:${range.start.line + 1}:${range.start.character + 1}`; + const message = isFlagged ? 'Flagged word ' : 'Unknown word '; + const rel = relative(uri); + const sugMsg = suggestions ? ` fix: (${suggestions.map((s) => colors.yellow(s.word)).join(', ')})` : ''; + return ` ${colors.green(rel)}${colors.yellow(pos)} - ${message} (${colors.red(text)})${sugMsg}`; +} + +export interface CheckDocumentsOptions { + log: typeof console.log; + error: typeof console.error; + output: (text: string) => void; + cancelationToken: CancellationToken; + forceCheck?: boolean; + width: number; +} + +export async function cmdCheckDocuments(uris: (string | Uri)[], options: CheckDocumentsOptions): Promise { + const count = uris.length; + for (let index = 0; index < count; index++) { + const uri = uris[index]; + if (options.cancelationToken.isCancellationRequested) { + return; + } + try { + await cmdCheckDocument(uri, options, index, count); + } catch (error) { + options.error(error); + return; + } + } +} diff --git a/packages/client/src/repl/cmdLs.mts b/packages/client/src/repl/cmdLs.mts new file mode 100644 index 000000000..eeb200c34 --- /dev/null +++ b/packages/client/src/repl/cmdLs.mts @@ -0,0 +1,80 @@ +import type { CancellationToken, Uri } from 'vscode'; +import { FileType } from 'vscode'; + +import { toError } from '../util/errors.js'; +import { combine, dim, green, red, yellow } from './ansiUtils.mjs'; +import type { ExDirEntry } from './fsUtils.mjs'; +import { globSearch, readDirStats, readStatOrError, readStatsForFiles, relativePath } from './fsUtils.mjs'; +import { normalizePatternBase } from './globUtils.mjs'; + +interface CmdLsOptions { + cwd: Uri; + log: typeof console.log; + dirSuffix?: string; + cancelationToken?: CancellationToken; +} + +const identity = (x: T) => x; + +export async function cmdLs(paths: string[] | undefined, options: CmdLsOptions) { + const { log, dirSuffix = '/' } = options; + for await (const entry of ls(paths, options.cwd, options.cancelationToken)) { + const [name, stat] = entry; + const isError = stat instanceof Error; + if (isError) { + log(red('Error: ') + `${yellow(name)}: No such file or directory`); + continue; + } + const fileType = stat.type; + const suffix = fileType & FileType.Directory ? dirSuffix : ''; + + let color = fileType & FileType.SymbolicLink ? yellow : fileType & FileType.Directory ? green : identity; + if (name.startsWith('.')) { + color = combine(color, dim); + } + + log(color(name + suffix)); + } +} + +async function* ls(paths: string[] | undefined, cwd: Uri, cancelationToken: CancellationToken | undefined): AsyncGenerator { + const collation = new Intl.Collator(undefined, { numeric: true, sensitivity: 'variant' }); + + if (!paths?.length) { + yield* readDirStats(cwd, false, cancelationToken); + return; + } + + for (const path of paths) { + if (cancelationToken?.isCancellationRequested) { + return; + } + const [glob, base] = normalizePatternBase(path, cwd); + + if (!glob) { + const stats = await readStatOrError(base); + if (stats instanceof Error) { + yield [path, stats]; + continue; + } + if (stats.type & FileType.Directory) { + yield* readDirStats(base, false, cancelationToken); + continue; + } + yield [path, stats]; + continue; + } + + try { + const uris = await globSearch(glob, base, undefined, undefined, cancelationToken); + + uris.sort((a, b) => collation.compare(a.path, b.path)); + + for await (const [uri, stats] of readStatsForFiles(uris, cancelationToken)) { + yield [relativePath(cwd, uri), stats]; + } + } catch (e) { + yield [path, toError(e)]; + } + } +} diff --git a/packages/client/src/repl/cmdSuggestions.mts b/packages/client/src/repl/cmdSuggestions.mts new file mode 100644 index 000000000..dda5ea1d6 --- /dev/null +++ b/packages/client/src/repl/cmdSuggestions.mts @@ -0,0 +1,26 @@ +import type { Uri } from 'vscode'; + +import * as di from '../di.js'; +import { colors } from './ansiUtils.mjs'; + +export async function cmdSuggestions(word: string, uri: Uri | string): Promise { + const client = di.get('client'); + + const result = await client.serverApi.spellingSuggestions(word, { uri: uri.toString() }); + const suggestions = result.suggestions; + + const lines: string[] = []; + + lines.push(`Suggestions for "${word}":`); + + if (!suggestions) { + lines.push(colors.yellow(' No suggestions')); + } + + for (const suggestion of suggestions) { + const sug = suggestion.isPreferred || suggestion.word === word ? colors.green(suggestion.word) : suggestion.word; + lines.push(` ${sug}${suggestion.isPreferred ? colors.yellow('*') : ''}`); + } + + return lines.join('\n'); +} diff --git a/packages/client/src/repl/cmdTrace.mts b/packages/client/src/repl/cmdTrace.mts new file mode 100644 index 000000000..3684fbed7 --- /dev/null +++ b/packages/client/src/repl/cmdTrace.mts @@ -0,0 +1,82 @@ +import { isDefined } from '@internal/common-utils'; +import asTable from 'as-table'; +import type { Uri } from 'vscode'; + +import * as di from '../di.js'; +import { colors } from './ansiUtils.mjs'; + +export interface TraceWordOptions { + /** Show all dictionaries */ + all?: boolean; + width: number; + onlyFound?: boolean; + onlyEnabled?: boolean; + filetype?: string; + allowCompoundWords?: boolean; +} + +export async function traceWord(word: string, uri: Uri | string, options: TraceWordOptions): Promise { + const { width } = options; + const client = di.get('client'); + const result = await client.serverApi.traceWord({ + word, + uri: uri.toString(), + searchAllDictionaries: true, + languageId: options.filetype, + allowCompoundWords: options.allowCompoundWords, + }); + + if (!result) { + return 'No trace results'; + } + + const lines: string[] = []; + + lines.push(`Trace: "${result.word}"`); + + if (result.errors) { + lines.push(colors.red('Errors:')); + lines.push(` ${result.errors}`); + } + + const toTable = asTable.configure({ maxTotalWidth: width }); + const filters = [ + options.all ? undefined : (trace: { found: boolean; dictEnabled: boolean }) => trace.found || trace.dictEnabled, + options.onlyFound ? (trace: { found: boolean }) => trace.found : undefined, + options.onlyEnabled ? (trace: { dictEnabled: boolean }) => trace.dictEnabled : undefined, + ].filter(isDefined); + + const filter = combineFilters(filters); + + for (const trace of result.traces || []) { + lines.push(`${trace.word}: ${trace.found ? '' : colors.yellow('Not found')}`); + + const entries = [...trace.traces].filter(filter).sort((a, b) => a.dictName.localeCompare(b.dictName)); + + const data = entries.map((line) => { + const dictionary = colors.yellow(line.dictName); + return { + Word: colorWord(line.word, line.foundWord, line.found), + F: line.found ? colors.whiteBright('*') : colors.dim('-'), + Dictionary: line.dictEnabled ? dictionary : colors.dim(dictionary), + }; + }); + + lines.push(toTable(data)); + } + + return lines.join('\n'); +} + +function combineFilters(filters: ((t: T) => boolean)[]): (t: T) => boolean { + return (t) => filters.every((f) => f(t)); +} + +function colorWord(word: string, foundWord: string | undefined, found: boolean): string { + if (foundWord) { + foundWord = foundWord.split('+').map(colors.green).join(colors.gray('+')); + } + const w = foundWord || colors.green(word); + + return found ? w : colors.dim(w); +} diff --git a/packages/client/src/repl/consoleDebug.mts b/packages/client/src/repl/consoleDebug.mts new file mode 100644 index 000000000..97ce4cee1 --- /dev/null +++ b/packages/client/src/repl/consoleDebug.mts @@ -0,0 +1,2 @@ +const debugMode = false; +export const consoleDebug: typeof console.error = debugMode ? console.debug : () => undefined; diff --git a/packages/client/src/repl/emitterToWriteStream.mts b/packages/client/src/repl/emitterToWriteStream.mts new file mode 100644 index 000000000..241e1cfa3 --- /dev/null +++ b/packages/client/src/repl/emitterToWriteStream.mts @@ -0,0 +1,253 @@ +import type { Socket } from 'node:net'; +import stream from 'node:stream'; +import type { Direction, WriteStream as TTYWriteStream } from 'node:tty'; + +import type * as vscode from 'vscode'; + +import { clearDown, clearLine, cursorTo, moveCursor } from './ansiUtils.mjs'; + +interface WriteStream extends stream.Writable, Omit {} + +const debug = false; +const consoleDebug = debug ? console.debug : () => {}; + +export function emitterToWriteStream(emitter: vscode.EventEmitter): WriteableEmitter { + return new WriteableEmitter(emitter); +} + +const allowedEncodings: Readonly> = { + ascii: true, + utf8: true, + utf16le: true, + ucs2: true, + base64: true, + base64url: true, + latin1: true, + binary: true, + hex: true, + 'utf-8': true, // Alias of 'utf8' + 'ucs-2': true, // Alias of 'usc2' +} as const satisfies Readonly>; + +class WriteableEmitter extends stream.Writable implements WriteStream { + #dimensions: vscode.TerminalDimensions = { columns: 80, rows: 24 }; + constructor(emitter: vscode.EventEmitter) { + super({ + write: (chunk, encoding, callback) => { + const enc = encoding in allowedEncodings ? encoding : undefined; + const str = chunk.toString(enc); + consoleDebug('write: %o', mapAnsiSequence(str)); + emitter.fire(str); + setTimeout(callback, 0); + }, + }); + } + + get dimensions() { + return this.#dimensions; + } + + set dimensions(value) { + this.#dimensions = value; + } + + /** + * `writeStream.clearLine()` clears the current line of this `WriteStream` in a + * direction identified by `dir`. + * @since v0.7.7 + * @param callback Invoked once the operation completes. + * @return `false` if the stream wishes for the calling code to wait for the `'drain'` event to be + * emitted before continuing to write additional data; otherwise `true`. + */ + clearLine(dir: Direction, callback?: () => void): boolean { + consoleDebug('clearLine: %o', dir); + return this.write(clearLine(dir), callback); + } + + /** + * `writeStream.clearScreenDown()` clears this `WriteStream` from the current + * cursor down. + * @since v0.7.7 + * @param callback Invoked once the operation completes. + * @return `false` if the stream wishes for the calling code to wait for the `'drain'` event to be + * emitted before continuing to write additional data; otherwise `true`. + */ + clearScreenDown(callback?: () => void): boolean { + consoleDebug('clearScreenDown'); + return this.write(clearDown(), callback); + } + + /** + * `writeStream.cursorTo()` moves this `WriteStream`'s cursor to the specified + * position. + * @since v0.7.7 + * @param callback Invoked once the operation completes. + * @return `false` if the stream wishes for the calling code to wait for the `'drain'` event to be + * emitted before continuing to write additional data; otherwise `true`. + */ + cursorTo(x: number, y?: number, callback?: () => void): boolean; + cursorTo(x: number, callback: () => void): boolean; + cursorTo(x: number, y?: number | (() => void), callback?: () => void): boolean { + consoleDebug('cursorTo: %o, %o', x, y); + callback = typeof y === 'function' ? y : callback; + y = typeof y === 'number' ? y : undefined; + return this.write(cursorTo(x, y), callback); + } + + /** + * `writeStream.moveCursor()` moves this `WriteStream`'s cursor _relative_ to its + * current position. + * @since v0.7.7 + * @param callback Invoked once the operation completes. + * @return `false` if the stream wishes for the calling code to wait for the `'drain'` event to be + * emitted before continuing to write additional data; otherwise `true`. + */ + moveCursor(dx: number, dy: number, callback?: () => void): boolean { + consoleDebug('moveCursor: %o, %o', dx, dy); + return this.write(moveCursor(dx, dy), callback); + } + + /** + * Returns: + * + * * `1` for 2, + * * `4` for 16, + * * `8` for 256, + * * `24` for 16,777,216 colors supported. + * + * Use this to determine what colors the terminal supports. Due to the nature of + * colors in terminals it is possible to either have false positives or false + * negatives. It depends on process information and the environment variables that + * may lie about what terminal is used. + * It is possible to pass in an `env` object to simulate the usage of a specific + * terminal. This can be useful to check how specific environment settings behave. + * + * To enforce a specific color support, use one of the below environment settings. + * + * * 2 colors: `FORCE_COLOR = 0` (Disables colors) + * * 16 colors: `FORCE_COLOR = 1` + * * 256 colors: `FORCE_COLOR = 2` + * * 16,777,216 colors: `FORCE_COLOR = 3` + * + * Disabling color support is also possible by using the `NO_COLOR` and`NODE_DISABLE_COLORS` environment variables. + * @since v9.9.0 + * @param [env=process.env] An object containing the environment variables to check. + * This enables simulating the usage of a specific terminal. + */ + getColorDepth(_env?: object): number { + consoleDebug('getColorDepth'); + return 256; + } + + /** + * Returns `true` if the `writeStream` supports at least as many colors as provided + * in `count`. Minimum support is 2 (black and white). + * + * This has the same false positives and negatives as described in `writeStream.getColorDepth()`. + * + * ```js + * process.stdout.hasColors(); + * // Returns true or false depending on if `stdout` supports at least 16 colors. + * process.stdout.hasColors(256); + * // Returns true or false depending on if `stdout` supports at least 256 colors. + * process.stdout.hasColors({ TMUX: '1' }); + * // Returns true. + * process.stdout.hasColors(2 ** 24, { TMUX: '1' }); + * // Returns false (the environment setting pretends to support 2 ** 8 colors). + * ``` + * @since v11.13.0, v10.16.0 + * @param [count=16] The number of colors that are requested (minimum 2). + * @param [env=process.env] An object containing the environment variables to check. + * This enables simulating the usage of a specific terminal. + */ + hasColors(count?: number): boolean; + hasColors(env?: object): boolean; + hasColors(count: number, env?: object): boolean; + hasColors(count?: number | object, _env?: object): boolean { + consoleDebug('hasColors: %o', count); + return typeof count !== 'number' || count <= 256; + } + + /** + * `writeStream.getWindowSize()` returns the size of the TTY + * corresponding to this `WriteStream`. The array is of the type`[numColumns, numRows]` + * where `numColumns` and `numRows` represent the number of columns and rows in the corresponding TTY. + * @since v0.7.7 + */ + getWindowSize(): [number, number] { + consoleDebug('getWindowSize: %o', this.#dimensions); + return [this.#dimensions.columns, this.#dimensions.rows]; + } + /** + * A `number` specifying the number of columns the TTY currently has. This property + * is updated whenever the `'resize'` event is emitted. + * @since v0.7.7 + */ + get columns(): number { + consoleDebug('columns: %o', this.#dimensions.columns); + return this.#dimensions.columns; + } + /** + * A `number` specifying the number of rows the TTY currently has. This property + * is updated whenever the `'resize'` event is emitted. + * @since v0.7.7 + */ + get rows(): number { + consoleDebug('rows: %o', this.#dimensions.rows); + return this.#dimensions.rows; + } + /** + * A `boolean` that is always `true`. + * @since v0.5.8 + */ + get isTTY() { + consoleDebug('isTTY'); + return true; + } +} + +class ReadableEmitter extends stream.Readable { + private buffer: string[] = []; + private paused = true; + private disposable: vscode.Disposable; + constructor(emitter: vscode.EventEmitter) { + super({}); + this.disposable = emitter.event((data) => { + this.buffer.push(data); + this.pushBuffer(); + }); + } + + _read() { + this.paused = false; + this.pushBuffer(); + } + + _destroy() { + this.disposable.dispose(); + } + + private pushBuffer() { + if (this.paused) return; + for (let data = this.buffer.shift(); data !== undefined && !this.paused; data = this.buffer.shift()) { + consoleDebug('read: %o', mapAnsiSequence(data)); + this.push(data); + } + } +} + +export function emitterToReadStream(emitter: vscode.EventEmitter): stream.Readable { + return new ReadableEmitter(emitter); +} + +const charMap: Record = { + '\x1b': '␛', + '\n': '↵', + '\r': '↤', + '\t': '⇥', + ' ': '␣', +}; + +function mapAnsiSequence(seq: string): string { + return [...seq].map((char) => charMap[char] || char).join(''); +} diff --git a/packages/client/src/repl/formatPath.mts b/packages/client/src/repl/formatPath.mts new file mode 100644 index 000000000..c8f8b5ea9 --- /dev/null +++ b/packages/client/src/repl/formatPath.mts @@ -0,0 +1,55 @@ +import type { Uri } from 'vscode'; +import * as vscode from 'vscode'; + +const urlLike = /^.*:/; +export function relative(uri: Uri | string): string { + const rel = vscode.workspace.asRelativePath(uri, false); + if (urlLike.test(rel)) return rel; + + return './' + rel.split('\\').join('/'); +} + +export function formatPath(uri: Uri | string, width: number): string { + let rel = typeof uri === 'string' ? uri : uri.path; + if (rel.length > width) { + const parts = rel.split('/'); + let i = 0; + let j = parts.length; + + let leftSide = ''; + let rightSide = ''; + const middle = '…'; + rel = middle; + let len = rel.length; + + while (i < j) { + const left = parts[i]; + const right = parts[j - 1]; + if (leftSide.length + left.length <= rightSide.length + right.length) { + if (len + left.length + 1 > width) break; + len += left.length + 1; + leftSide += left + '/'; + i++; + } else { + if (len + right.length + 1 > width) break; + len += right.length + 1; + rightSide = '/' + right + rightSide; + j--; + } + console.log('%o', { + leftSide, + rightSide, + middle, + rel: leftSide + middle + rightSide, + len, + len2: (leftSide + middle + rightSide).length, + }); + } + rel = leftSide + middle + rightSide; + if (rel === middle) { + rightSide = parts[parts.length - 1]; + rel = middle + rightSide.slice(-width + 1); + } + } + return rel; +} diff --git a/packages/client/src/repl/formatPath.test.mts b/packages/client/src/repl/formatPath.test.mts new file mode 100644 index 000000000..b95bd9a0f --- /dev/null +++ b/packages/client/src/repl/formatPath.test.mts @@ -0,0 +1,25 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { formatPath } from './formatPath.mjs'; + +vi.mock('vscode'); + +describe('formatPath', () => { + test.each` + path | width | expected + ${'a/b/c'} | ${15} | ${'a/b/c'} + ${'one'} | ${15} | ${'one'} + ${'one/two'} | ${15} | ${'one/two'} + ${'one/two/three'} | ${15} | ${'one/two/three'} + ${'one/two/three/four'} | ${15} | ${'one/two/…/four'} + ${'one/two/three/four/five'} | ${13} | ${'one/…/five'} + ${'one/two/three/four/five'} | ${14} | ${'one/two/…/five'} + ${'one/two/three/four/five'} | ${18} | ${'one/two/…/five'} + ${'one/two/three/four/five'} | ${19} | ${'one/two/…/four/five'} + ${'one_two_three_four_five'} | ${11} | ${'…_four_five'} + `('formatPath $path, $width', ({ path, width, expected }) => { + const rel = formatPath(path, width); + expect(rel).toBe(expected); + expect(rel.length).toBeLessThanOrEqual(width); + }); +}); diff --git a/packages/client/src/repl/fsUtils.mts b/packages/client/src/repl/fsUtils.mts new file mode 100644 index 000000000..f1cf4ba10 --- /dev/null +++ b/packages/client/src/repl/fsUtils.mts @@ -0,0 +1,115 @@ +import { homedir } from 'node:os'; +import { relative as pathRelative } from 'node:path/posix'; + +import type { CancellationToken, FileStat, FileType } from 'vscode'; +import { Uri } from 'vscode'; +import * as vscode from 'vscode'; + +import { toError } from '../util/errors.js'; +import { asyncQueue } from './asyncQueue.mjs'; +import { consoleDebug } from './consoleDebug.mjs'; +import { toGlobPattern } from './globUtils.mjs'; + +export type DirEntryStat = Partial & Pick; +export type ExDirEntry = [string, DirEntryStat | Error]; + +export type DirEntry = [string, FileType]; +export type UriStats = [Uri, FileStat]; + +export function currentDirectory(): Uri { + return vscode.workspace.workspaceFolders?.[0].uri || uriParent(getCurrentDocumentUri()) || Uri.file('.'); +} + +function getCurrentDocumentUri(): Uri | undefined { + const editor = vscode.window.activeTextEditor; + return editor?.document.uri; +} + +function uriParent(uri: Uri | undefined): Uri | undefined { + return uri && Uri.joinPath(uri, '..'); +} + +export async function* readStatsForFiles(uris: Uri[], cancelationToken: vscode.CancellationToken | undefined): AsyncGenerator { + if (cancelationToken?.isCancellationRequested) { + return []; + } + + const statsRequests = uris.map((uri) => async () => [uri, await vscode.workspace.fs.stat(uri)] as UriStats); + + for await (const result of asyncQueue(statsRequests, 10)) { + if (cancelationToken?.isCancellationRequested) { + break; + } + yield result; + } + + return; +} + +export async function globSearch( + pattern: string, + base: Uri | undefined, + excludePattern: string | undefined, + maxResults: number | undefined, + cancelationToken?: vscode.CancellationToken, +): Promise { + const pat = toGlobPattern(pattern, base); + const result = await vscode.workspace.findFiles( + pat, + excludePattern && toGlobPattern(excludePattern, base), + maxResults, + cancelationToken, + ); + if (cancelationToken?.isCancellationRequested) { + consoleDebug('globSearch cancelled'); + } + return result; +} + +export function resolvePath(relPath: string | Uri | undefined, cwd?: Uri): Uri { + if (typeof relPath === 'string' && relPath.startsWith('~')) { + cwd = Uri.file(homedir()); + relPath = relPath.slice(2); + } + return typeof relPath === 'string' ? Uri.joinPath(cwd || currentDirectory(), relPath) : relPath || currentDirectory(); +} + +export async function readDir(relUri?: string | Uri | undefined, cwd?: Uri): Promise { + const uri = resolvePath(relUri, cwd); + return await vscode.workspace.fs.readDirectory(uri); +} + +export async function* readDirStats(dirUri: Uri, extendedStats = false, cancelationToken?: CancellationToken): AsyncGenerator { + if (cancelationToken?.isCancellationRequested) return; + + for await (const [name, type] of await vscode.workspace.fs.readDirectory(dirUri)) { + if (cancelationToken?.isCancellationRequested) return; + const stat = extendedStats ? await vscode.workspace.fs.stat(Uri.joinPath(dirUri, name)) : { type }; + yield [name, stat] as ExDirEntry; + } +} + +/** + * Like `vscode.workspace.asRelativePath` but returns the folder name for workspace folders. + * @param uri - uri to convert to a relative path + * @returns + */ +export function toRelativeWorkspacePath(uri: Uri | undefined): string | undefined { + if (!uri) return; + const uriHref = uri.toString().replace(/\/$/, ''); + const folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.toString() === uriHref); + if (folder) return folder.name + '/'; + return vscode.workspace.asRelativePath(uri, true); +} + +export function relativePath(from: Uri, to: Uri): string { + return pathRelative(from.path, to.path); +} + +export async function readStatOrError(uri: Uri): Promise { + try { + return await vscode.workspace.fs.stat(uri); + } catch (e) { + return toError(e); + } +} diff --git a/packages/client/src/repl/fsUtils.test.mts b/packages/client/src/repl/fsUtils.test.mts new file mode 100644 index 000000000..a6d8f8ae9 --- /dev/null +++ b/packages/client/src/repl/fsUtils.test.mts @@ -0,0 +1,22 @@ +import { describe, expect, test, vi } from 'vitest'; +import { Uri } from 'vscode'; + +import { relativePath } from './fsUtils.mjs'; + +vi.mock('vscode'); + +describe('fsUtils', () => { + test.each` + a | b | expected + ${'a/b/c'} | ${'a/b/c/d'} | ${'d'} + ${'a/b/c'} | ${'a/c/d'} | ${'../../c/d'} + ${'a/b/'} | ${'a/c/d'} | ${'../c/d'} + ${'a/b/c'} | ${'a/b/c'} | ${''} + `('relativePath $a $b', ({ a, b, expected }) => { + expect(relativePath(u(a), u(b))).toBe(expected); + }); +}); + +function u(p: string | Uri): Uri { + return p instanceof Uri ? p : Uri.joinPath(Uri.file(process.cwd()), p); +} diff --git a/packages/client/src/repl/globUtils.mts b/packages/client/src/repl/globUtils.mts new file mode 100644 index 000000000..012d73a60 --- /dev/null +++ b/packages/client/src/repl/globUtils.mts @@ -0,0 +1,43 @@ +import { homedir } from 'node:os'; + +import type { GlobPattern } from 'vscode'; +import { RelativePattern, Uri } from 'vscode'; + +import { currentDirectory } from './fsUtils.mjs'; + +export function toGlobPattern(pattern: string, url?: Uri): GlobPattern { + return new RelativePattern(url || currentDirectory(), pattern); +} +export function globsToGlob(glob: string): string; +export function globsToGlob(globs: [string, ...string[]]): string; +export function globsToGlob(globs: string[] | string | undefined): string | undefined; +export function globsToGlob(globs: string[] | string | undefined): string | undefined { + if (!globs) return undefined; + if (typeof globs === 'string') return globs; + return globs.length > 1 ? `{${globs.join(',')}}` : globs[0]; +} + +/** + * Try to normalize a relative glob pattern and base uri. + * @param pattern - glob pattern with possible `./` and `../` prefixes. + * @param base - the starting uri for the pattern. + * @returns [normalizedPattern, normalizedBaseUri] + */ +export function normalizePatternBase(pattern: string, base: Uri): [string, Uri] { + if (pattern === '~' || pattern.startsWith('~/')) { + base = Uri.file(homedir()); + pattern = pattern.slice(2); + } + if (!containsGlobPattern(pattern)) return ['', Uri.joinPath(base, pattern)]; + + const parts = pattern.split('/'); + let i = 0; + for (; i < parts.length && !containsGlobPattern(parts[i]); ++i) { + base = Uri.joinPath(base, parts[i]); + } + return [parts.slice(i).join('/'), base]; +} + +export function containsGlobPattern(pattern: string): boolean { + return /[*?{}[\]]/.test(pattern); +} diff --git a/packages/client/src/repl/globUtils.test.mts b/packages/client/src/repl/globUtils.test.mts new file mode 100644 index 000000000..9ab3f07bd --- /dev/null +++ b/packages/client/src/repl/globUtils.test.mts @@ -0,0 +1,25 @@ +import { describe, expect, test, vi } from 'vitest'; +import { Uri } from 'vscode'; + +import { normalizePatternBase } from './globUtils.mjs'; + +vi.mock('vscode'); + +describe('globUtils', () => { + test.each` + pattern | base | expected + ${'**/*.ts'} | ${u('.')} | ${['**/*.ts', u('.')]} + ${'../**/*.ts'} | ${u('.')} | ${['**/*.ts', u('..')]} + ${'./**/*.ts'} | ${u('.')} | ${['**/*.ts', u('.')]} + ${'./one/two/three/*.ts'} | ${u('.')} | ${['*.ts', u('./one/two/three')]} + ${'./one/two/three/'} | ${u('.')} | ${['', u('./one/two/three/')]} + ${'./one/two/three'} | ${u('.')} | ${['', u('./one/two/three')]} + `('normalizePatternBase $pattern', ({ pattern, base, expected }) => { + const r = normalizePatternBase(pattern, base); + expect(r).toEqual(expected); + }); +}); + +function u(p: string) { + return Uri.joinPath(Uri.file(process.cwd()), p); +} diff --git a/packages/client/src/repl/index.ts b/packages/client/src/repl/index.ts new file mode 100644 index 000000000..45fbfa010 --- /dev/null +++ b/packages/client/src/repl/index.ts @@ -0,0 +1,4 @@ +export async function createTerminal() { + const { createTerminal } = await import('./repl.mjs'); + return createTerminal(); +} diff --git a/packages/client/src/repl/parseCommandLine.test.ts b/packages/client/src/repl/parseCommandLine.test.ts new file mode 100644 index 000000000..d0e774cd6 --- /dev/null +++ b/packages/client/src/repl/parseCommandLine.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest'; + +import { commandLineBuilder, parseCommandLine, parseCommandLineIntoArgs } from './parseCommandLine.js'; + +describe('parseArgs', () => { + test.each` + line | expected + ${''} | ${[]} + ${'a b c'} | ${['a', 'b', 'c']} + ${'a "" c'} | ${['a', '', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'"a b" c'} | ${['a b', 'c']} + ${'"a\\ b" c'} | ${['a\\ b', 'c']} + ${'a\\\\\\g'} | ${['a\\g']} + ${'a\\"'} | ${['a"']} + `('parseArgs $line', ({ line, expected }) => { + expect(parseCommandLineIntoArgs(line)).toEqual(expected); + }); + + test.each` + line | expected + ${''} | ${[]} + ${'a b c'} | ${['a', 'b', 'c']} + ${'a "" c'} | ${['a', '', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'"a b" c'} | ${['a b', 'c']} + ${'"a\\ b" c'} | ${['a\\ b', 'c']} + ${'a\\\\\\g'} | ${['a\\g']} + ${'a\\"'} | ${['a"']} + ${'a "b c'} | ${['a', 'b c']} + ${"a 'b c "} | ${['a', 'b c ']} + `('parseCommandLine $line', ({ line, expected }) => { + const result = parseCommandLine(line); + expect(result.args).toEqual(expected); + expect(result.line).toBe(line); + expect(result.tokens.map((t) => t.original).join('')).toBe(line); + }); +}); + +describe('CommandLineBuilder', () => { + test.each` + line | expected + ${''} | ${[]} + ${'a b c'} | ${['a', 'b', 'c']} + ${'a "" c'} | ${['a', '', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'a\\ b c'} | ${['a b', 'c']} + ${'"a b" c'} | ${['a b', 'c']} + ${'"a\\ b" c'} | ${['a\\ b', 'c']} + ${'a\\\\\\g'} | ${['a\\g']} + ${'a\\"'} | ${['a"']} + ${'a "b c'} | ${['a', 'b c']} + ${"a 'b c "} | ${['a', 'b c ']} + ${'check \\\n "*.md"'} | ${['check', '*.md']} + ${'echo "hello \\\nthere'} | ${['echo', 'hello there']} + ${'echo "hello \\\r\nthere'} | ${['echo', 'hello there']} + ${'echo "hello \\\n\r\nthere'} | ${['echo', 'hello \r\nthere']} + `('commandLineBuilder $line', ({ line, expected }) => { + const builder = commandLineBuilder(line); + expect(builder.args).toEqual(expected); + expect(builder.line).toBe(line); + expect(builder.tokens.map((t) => t.original).join('')).toBe(line); + }); + + test('commandLineBuilder set and add', () => { + const builder = commandLineBuilder(''); + expect(builder.args).toEqual([]); + expect(builder.line).toBe(''); + builder.pushArg('check'); + expect(builder.args).toEqual(['check']); + expect(builder.line).toBe('check'); + builder.pushArg('*.md'); + expect(builder.args).toEqual(['check', '*.md']); + expect(builder.line).toBe('check *.md'); + builder.setArg(1, '*.txt', '"'); + expect(builder.args).toEqual(['check', '*.txt']); + expect(builder.line).toBe('check "*.txt"'); + builder.pushArg('--sep=&'); + expect(builder.args).toEqual(['check', '*.txt', '--sep=&']); + expect(builder.line).toBe('check "*.txt" --sep=\\&'); + expect(builder.hasTrailingSeparator()).toBe(false); + builder.pushSeparator(); + expect(builder.hasTrailingSeparator()).toBe(true); + }); +}); diff --git a/packages/client/src/repl/parseCommandLine.ts b/packages/client/src/repl/parseCommandLine.ts new file mode 100644 index 000000000..97e0c07e5 --- /dev/null +++ b/packages/client/src/repl/parseCommandLine.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert'; + +const escapeSequenceMap: Record = { + n: '\n', + r: '\r', + t: '\t', + // b: '\b', + f: '\f', + // 0: '\0', + '"': '"', + "'": "'", +}; + +export function parseCommandLineIntoArgs(line: string): string[] { + return argTokensToArgs(parseCommandLineIntoTokens(line)); +} + +type QuoteChar = '"' | "'" | '`' | ''; + +interface ArgToken { + /** The parsed value */ + value: string; + /** + * The original value before parsing. + * tokens.map(t => t.original).join('') === line + */ + original: string; + /** + * The quote character used to parse the value. + */ + quote: QuoteChar; + /** + * True if the token is an argument separator instead of an argument value. + */ + separator: boolean; + /** + * The index of the token in the original line. + */ + i?: number; +} + +const separatorChars = [' ', '\t', '\n', '\r']; + +export function parseCommandLineIntoTokens(line: string): ArgToken[] { + const tokens: ArgToken[] = []; + + let i = 0; + let j = 0; + let arg: string | undefined; + + while (i < line.length) { + const c = line[i]; + switch (c) { + case '\n': + case '\r': + case '\t': + case ' ': + pushToken(arg); + arg = undefined; + pushToken(parseSeparator(), true); + break; + case '\\': + if (['\n', '\r'].includes(line[i + 1])) { + pushToken(arg); + arg = undefined; + pushToken(parseSeparator(), true); + continue; + } + arg = (arg || '') + parseSlash(); + break; + case '"': + case "'": + case '`': + pushToken(arg); + arg = undefined; + pushToken(parseQuote(), false, c as QuoteChar); + break; + default: + arg = (arg || '') + c; + ++i; + break; + } + } + + pushToken(arg); + + return tokens; + + function pushToken(value: string | undefined, separator = false, quote: QuoteChar = '') { + if (value !== undefined) { + tokens.push({ i, value: value, original: line.slice(j, i), quote, separator }); + j = i; + } + } + + function parseSeparator() { + let sep = line[i++]; + for (; i < line.length && separatorChars.includes(line[i]); i++) { + sep += line[i]; + } + return sep; + } + + function parseSlash() { + assert(line[i] === '\\'); + ++i; + if (i >= line.length) { + return '\\'; + } + return line[i++]; + } + + function parseQuoteEscape() { + if (i >= line.length) { + return '\\'; + } + const c = line[i++]; + if (c === '\n' || c === '\r') { + if (c === '\r' && line[i] === '\n') { + ++i; + } + return ''; + } + if (c in escapeSequenceMap) return escapeSequenceMap[c]; + return '\\' + c; + } + + function parseQuote() { + const quote = line[i++]; + let arg = ''; + while (i < line.length) { + const c = line[i++]; + if (c === '\\') { + arg += parseQuoteEscape(); + } else if (c === quote) { + return arg; + } else { + arg += c; + } + } + return arg; + } +} + +const escapeQuotedStringMap: Record = { + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\f': '\\f', + '\\': '\\\\', +}; + +/** + * Escape characters that are special in a command line. + * + * The following have not been included to support future support of environment variables. + * `'(', ')', '$'` + */ +const specialCommandLineChars = [' ', '&', ';', '|', '<', '>', '`', '"', "'", '\\']; +const specialCommandLineCharsMap = Object.fromEntries(specialCommandLineChars.map((c) => [c, '\\' + c])); + +const escapeMap: Record = { ...escapeQuotedStringMap, ...specialCommandLineCharsMap }; + +export interface ParsedCommandLine { + readonly line: string; + readonly args: string[]; + readonly tokens: ArgToken[]; +} + +export function parseCommandLine(line: string): ParsedCommandLine { + return new CParsedCommandLine(parseCommandLineIntoTokens(line)); +} + +export function argTokensToArgs(tokens: ArgToken[]): string[] { + const args = argTokensToArgWithTokens(tokens); + return args.map((a) => a.arg); +} + +interface ArgWithTokens { + arg: string; + startTokenIndex: number; + tokens: ArgToken[]; +} + +export function argTokensToArgWithTokens(tokens: ArgToken[]): ArgWithTokens[] { + const args: ArgWithTokens[] = []; + let arg: ArgWithTokens | undefined = undefined; + for (const [index, t] of tokens.entries()) { + if (t.separator) { + if (arg !== undefined) { + args.push(arg); + arg = undefined; + } + continue; + } + if (!arg) { + arg = { arg: t.value, startTokenIndex: index, tokens: [t] }; + continue; + } + arg.arg += t.value; + arg.tokens.push(t); + } + + if (arg !== undefined) { + args.push(arg); + } + + return args; +} + +class CParsedCommandLine implements ParsedCommandLine { + constructor(readonly tokens: ArgToken[]) {} + get line() { + return this.tokens.map((t) => t.original).join(''); + } + get args() { + return argTokensToArgs(this.tokens); + } +} + +export function commandLineBuilder(line: string): CommandLineBuilder { + return new CommandLineBuilder(parseCommandLineIntoTokens(line)); +} + +export class CommandLineBuilder { + constructor(readonly tokens: Readonly[] = []) {} + + get line() { + return this.tokens.map((t) => t.original).join(''); + } + get args() { + return argTokensToArgs(this.tokens); + } + + setArg(index: number, value: string, quote?: QuoteChar) { + const args = argTokensToArgWithTokens(this.tokens); + const arg = args[index]; + assert(arg, 'Invalid index'); + quote ??= arg.tokens[0].quote; + this.tokens.splice(arg.startTokenIndex, arg.tokens.length, { value, original: encodeArg(value, quote), quote, separator: false }); + } + + pushArg(value: string, quote: QuoteChar = '') { + const token: ArgToken = { value, original: encodeArg(value, quote), quote, separator: false }; + const lastToken = this.tokens[this.tokens.length - 1]; + if (lastToken && !lastToken.separator) { + this.pushSeparator(); + } + this.tokens.push(token); + } + + hasTrailingSeparator() { + return this.tokens[this.tokens.length - 1]?.separator || false; + } + + pushSeparator(value: ' ' | '\t' | '\n' | '\r' = ' ') { + this.tokens.push({ value, original: value, quote: '', separator: true }); + } + + clone() { + return new CommandLineBuilder([...this.tokens]); + } +} + +export function encodeArg(arg: string, quote: QuoteChar = '') { + if (!arg) return quote + quote || '""'; + + if (quote) { + return quoteString(arg, quote); + } + + return escapeArg(arg); +} + +function quoteString(value: string, quote: QuoteChar): string { + assert(quote === '"' || quote === "'", 'Invalid quote'); + + let arg = ''; + + for (let i = 0; i < value.length; i++) { + const c = value[i]; + const v = escapeQuotedStringMap[c] || c; + if (v === quote) { + arg += '\\' + quote; + } else { + arg += v; + } + } + + return quote + arg + quote; +} + +function escapeArg(value: string): string { + if (!value) return '""'; + + let arg = ''; + + for (let i = 0; i < value.length; i++) { + const c = value[i]; + arg += escapeMap[c] || c; + } + + return arg; +} diff --git a/packages/client/src/repl/repl.mts b/packages/client/src/repl/repl.mts new file mode 100644 index 000000000..f23c495a4 --- /dev/null +++ b/packages/client/src/repl/repl.mts @@ -0,0 +1,482 @@ +import assert from 'node:assert'; +import readline from 'node:readline/promises'; +import { formatWithOptions } from 'node:util'; + +import camelize from 'camelize'; +import * as vscode from 'vscode'; + +import { clearScreen, crlf, eraseLine, green, red, yellow } from './ansiUtils.mjs'; +import { Application, Command, defArg, defOpt } from './args.mjs'; +import { cmdCheckDocuments } from './cmdCheck.mjs'; +import { cmdLs } from './cmdLs.mjs'; +import { cmdSuggestions } from './cmdSuggestions.mjs'; +import { traceWord } from './cmdTrace.mjs'; +import { consoleDebug } from './consoleDebug.mjs'; +import { emitterToReadStream, emitterToWriteStream } from './emitterToWriteStream.mjs'; +import type { DirEntry } from './fsUtils.mjs'; +import { currentDirectory, globSearch, readDir, resolvePath, toRelativeWorkspacePath } from './fsUtils.mjs'; +import { globsToGlob } from './globUtils.mjs'; +import { commandLineBuilder, parseCommandLineIntoArgs } from './parseCommandLine.js'; + +const defaultWidth = 80; + +export function createTerminal() { + const pty = new Repl(); + const terminal = vscode.window.createTerminal({ name: 'Spell Checker REPL', pty }); + terminal.show(); +} + +class Repl implements vscode.Disposable, vscode.Pseudoterminal { + readonly #emitterInput = new vscode.EventEmitter(); + readonly #emitterOutput = new vscode.EventEmitter(); + readonly #emitterOnDidClose = new vscode.EventEmitter(); + readonly onDidWrite = this.#emitterOutput.event; + readonly onDidClose = this.#emitterOnDidClose.event; + readonly handleInput = (data: string) => this.#emitterInput.fire(data); + readonly #writeStream = emitterToWriteStream(this.#emitterOutput); + readonly #readStream = emitterToReadStream(this.#emitterInput); + readonly #output = (value: string) => this.#emitterOutput.fire(value); + readonly #controller = new AbortController(); + readonly #abortable = { signal: this.#controller.signal }; + #cwd = currentDirectory(); + #cancelationTokenSource: vscode.CancellationTokenSource | undefined; + #rl: readline.Interface | undefined; + #closed = false; + #dimensions: vscode.TerminalDimensions | undefined; + #application: Application | undefined; + + constructor() {} + + open(dimensions: vscode.TerminalDimensions | undefined) { + consoleDebug('Repl.open'); + assert(!this.#rl, 'Repl already open'); + assert(!this.#closed, 'Repl already closed'); + if (dimensions) { + this.#dimensions = dimensions; + this.#writeStream.dimensions = dimensions; + } + this.#rl = readline.createInterface({ + input: this.#readStream, + output: this.#writeStream, + completer: this.completer, + terminal: true, + }); + const rl = this.#rl; + rl.on('SIGTSTP', () => undefined); + rl.on('SIGINT', this.#cancelAction); + rl.on('close', () => (consoleDebug('rl close'), this.close())); + rl.on('line', this.#processLine); + this.#dimensions = dimensions; + if (dimensions) { + this.log('CSpell REPL'); + this.log('Type "help" or "?" for help.'); + this.#prompt(); + } + } + + setDimensions(dimensions: vscode.TerminalDimensions) { + this.#dimensions = dimensions; + this.#writeStream.dimensions = dimensions; + consoleDebug('Repl.setDimensions %o', dimensions); + this.#updatePrompt(); + } + + dispose = () => { + consoleDebug('Repl.dispose'); + this.close(); + }; + + #processLine = (line: string) => { + line = line.trim(); + consoleDebug('Repl.processLine %o', { line, args: parseCommandLineIntoArgs(line) }); + + const parseAsync = async () => { + if (!line) return; + if (line === '?') { + this.showHelp(); + return; + } + + const argv = parseCommandLineIntoArgs(line); + return this.#getApplication().exec(argv, this.log); + }; + + this.#prompt(parseAsync()); + }; + + #getApplication(): Application { + if (this.#application) return this.#application; + const app = new Application('CSpell REPL'); + const cmdCheck = new Command( + 'check', + 'Spell check the files matching the globs.', + { ...defArg('globs', 'string[]', 'File glob patterns.') }, + {}, + (args) => this.#cmdCheck(args.args.globs), + ); + + const cmdEcho = new Command( + 'echo', + 'Echo the values.', + { ...defArg('values', 'string[]', 'Echo the values to the console.') }, + {}, + (args) => this.#cmdEcho(args.args.values), + ); + + const cmdTrace = new Command( + 'trace', + 'Trace which dictionaries contain the word.', + { ...defArg('word', 'string', 'The word to trace.', true) }, + { + ...defOpt('all', 'boolean', 'Show all dictionaries.', ''), + ...defOpt('only-found', 'boolean', 'Show only found dictionaries.', 'f'), + ...defOpt('only-enabled', 'boolean', 'Show only enabled dictionaries.', ''), + ...defOpt('filetype', 'string', 'The file type to use. Example: `python`', 't'), + ...defOpt('allow-compound-words', 'boolean', 'Allow compound words.', ''), + }, + (args) => this.#cmdTrace(args.args, args.options), + ); + + const cmdSuggest = new Command( + 'suggestions', + 'Generate suggestions for a word.', + { ...defArg('word', 'string', 'The word to make suggestions.', true) }, + { ...defOpt('max', 'number', 'The maximum number of suggestions to generate.', '') }, + (args) => this.#cmdSuggestions(args.args.word, args.options), + ); + + const cmdPwd = new Command('pwd', 'Print the current working directory.', {}, {}, () => this.#cmdPwd()); + + const cmdCd = new Command( + 'cd', + 'Change the current working directory.', + { ...defArg('path', 'string', 'The path to change to.') }, + {}, + (args) => this.#cmdCd(args.args.path), + ); + + const cmdLs = new Command( + 'ls', + 'List the directory contents.', + { ...defArg('paths', 'string[]', 'The paths to list.') }, + {}, + (args) => this.#cmdLs(args.args), + ); + + const cmdEnv = new Command( + 'env', + 'Show environment variables.', + { ...defArg('filter', 'string[]', 'Optional filter.') }, + {}, + (args) => this.#cmdEnv(args.args.filter), + ); + + const cmdExit = new Command('exit', 'Exit the REPL.', {}, {}, () => { + this.log('Exiting...'); + this.close(); + }); + + const cmdHelp = new Command( + 'help', + 'Show help.', + { + command: { type: 'string', description: 'Show Help', required: false }, + }, + {}, + (args) => this.showHelp(args.args.command), + ); + + const cmdCls = new Command('cls', 'Clear the screen.', {}, {}, () => this.#output(clearScreen())); + + const cmdInfo = new Command('info', 'Show information about the REPL.', {}, {}, () => { + this.log('CSpell REPL'); + this.log('Type "help" or "?" for help.'); + this.log('Working Directory: %s', green(this.#cwd.toString(true))); + this.log('Dimensions: %o', this.#dimensions); + }); + + const commands = [cmdCheck, cmdEcho, cmdTrace, cmdPwd, cmdCd, cmdLs, cmdEnv, cmdExit, cmdHelp, cmdCls, cmdInfo, cmdSuggest]; + app.addCommands(commands); + this.#application = app; + return app; + } + + showHelp(command?: string) { + this.log(this.#getApplication().getHelp(command)); + } + + #prompt(waitFor?: Promise) { + const p = async () => { + try { + await waitFor; + if (waitFor) { + // clean up the action. + this.#cancelationTokenSource?.dispose(); + this.#cancelationTokenSource = undefined; + } else { + if (this.#cancelationTokenSource) { + // It is busy, do not display the prompt. + // It is possible to get here if the dimensions of the window change + // while in the middle of an action. + return; + } + } + } catch (e) { + if (e instanceof Error) { + this.error(e.message); + } + // empty + } + this.#updatePrompt(); + this.#rl?.prompt(); + if (this.#rl) { + const { cursor, line, terminal } = this.#rl; + consoleDebug('cursor pos: %o', { cursorPos: this.#rl.getCursorPos(), dim: this.#dimensions, cursor, line, terminal }); + } + }; + p(); + } + + #updatePrompt() { + this.#rl?.setPrompt(`cspell ${green(toRelativeWorkspacePath(vscode.Uri.joinPath(this.#cwd, '/')) || '')} > `); + } + + async #cmdCheck(globs: string[] | undefined) { + consoleDebug('Repl.cmdCheck'); + const { log, error } = this; + const output = this.#output; + + let pattern = globsToGlob(globs); + if (!pattern) { + pattern = await this.#rl?.question('File glob pattern: ', this.#abortable); + } + if (!pattern) return; + + output(eraseLine() + 'Gathering Files...'); + + const cfgSearchExclude = vscode.workspace.getConfiguration('search.exclude') as { [key: string]: boolean }; + const searchExclude = Object.keys(cfgSearchExclude).filter((k) => cfgSearchExclude[k] === true); + const excludePattern = globsToGlob(searchExclude); + const files = await globSearch(pattern, currentDirectory(), excludePattern, undefined, this.#getCancelationTokenForAction()); + + log(eraseLine() + 'Checking...'); + + await cmdCheckDocuments(files, { log, error, output, cancelationToken: this.#getCancelationTokenForAction(), width: this.width }); + } + + async #cmdEcho(globs: string[] | undefined) { + consoleDebug('Repl.cmdEcho'); + this.log((globs || []).join(' ')); + } + + getCommandNames() { + return this.#getApplication().getCommandNames().sort(); + } + + completer = async (line: string): Promise<[string[], string]> => { + const commands = this.getCommandNames(); + if (!line.trim()) return [commands, '']; + + const cmdLine = commandLineBuilder(line); + const args = cmdLine.args; + if (args.length === 1 && line === args[0]) { + return [this.#completeWithOptions(line, commands).map((line) => line + ' '), line]; + } + + const command = args[0]; + + const argIndex = cmdLine.hasTrailingSeparator() ? 0 : args.length - 1; + const current = argIndex ? args[argIndex] : ''; + const results = await this.#cmdCompletion(command, current, args); + + const completions = results.map((c) => { + const cmd = cmdLine.clone(); + if (argIndex) { + cmd.setArg(argIndex, c); + } else { + cmd.pushArg(c); + } + return cmd.line; + }); + + return [completions.filter((c) => c.startsWith(line)), line]; + }; + + #cmdCompletion = async (command: string, current: string, args: string[]) => { + console.error('Repl.cmdCompletion %o', { command, current }); + switch (command) { + case 'check': + return this.#cmdCheckCompletion(current); + case 'cd': + return this.#cmdCdCompletion(current, args); + case 'ls': + return this.#completeWithPath(current, false); + case 'help': + return this.#completeWithOptions(current, this.getCommandNames()); + } + return []; + }; + + #cmdCheckCompletion = async (current: string) => { + return this.#completeWithPath(current, false); + }; + + #cmdCdCompletion = async (current: string, args: string[]) => { + if (args.length > 2 || (args.length === 2 && args[1] !== current)) return []; + return this.#completeWithPath(current, true); + }; + + #completeWithPath = async (current: string, directoriesOnly: boolean) => { + try { + const files = await this.readDirEntryNames(current, directoriesOnly ? vscode.FileType.Directory : undefined); + return this.#completeWithOptions( + current, + files.map((f) => (f.startsWith('-') ? `./${f}` : f)), + ); + } catch { + return []; + } + }; + + #completeWithOptions = (current: string, options: string[]): string[] => { + // console.error('Repl.completeWithOptions %o', { current, options }); + const matchingCommands = options.filter((c) => c.startsWith(current)); + const prefix = findLongestPrefix(matchingCommands); + return prefix && prefix !== current ? [prefix] : matchingCommands; + }; + + #cmdPwd() { + consoleDebug('Repl.cmdPwd'); + this.log(this.#cwd?.toString(false) || 'No Working Directory'); + } + + #cmdEnv(filter?: string[] | undefined) { + consoleDebug('Repl.cmdEnv'); + this.log('Environment:'); + const entries = Object.entries(process.env) + .filter(([key]) => !filter || filter.find((f) => key.toLowerCase().includes(f))) + .sort((a, b) => a[0].localeCompare(b[0])); + for (const [key, value] of entries) { + this.log(`${green(key)}${yellow('=')}${value}`); + } + } + + async #cmdTrace(args: { word?: string | undefined }, options: { all?: boolean | undefined }) { + consoleDebug('Repl.cmdTrace %o', args); + const { word } = args; + if (!word) { + this.log('No word specified.'); + return; + } + const result = await traceWord(word, this.#cwd, { ...camelize(options), width: this.width }); + this.log('%s', result); + } + + get width() { + return this.#dimensions?.columns || defaultWidth; + } + + async #cmdLs(args: { paths?: string[] | undefined }) { + consoleDebug('Repl.cmdLs %o', args); + + await cmdLs(args.paths, { log: this.log, cwd: this.#cwd, cancelationToken: this.#getCancelationTokenForAction() }); + } + + async readDir(relUri?: string | vscode.Uri | undefined): Promise { + return readDir(relUri, this.#cwd); + } + + async readDirEntryNames(relativePartialPath: string, filterType?: vscode.FileType): Promise { + const relPath = relativePartialPath.split('/').slice(0, -1).join('/'); + const filterFn = filterType ? ([, type]: Readonly<[string, vscode.FileType]>) => type & filterType : () => true; + const defaultDir: DirEntry[] = relPath ? [] : [['..', vscode.FileType.Directory]]; + + const relPrefix = relPath ? relPath + '/' : ''; + + const dirInfo = [...defaultDir, ...(await this.readDir(relPath)).map(([name, type]) => [relPrefix + name, type] as DirEntry)] + .filter(filterFn) + .map(([name, type]) => name + (type & vscode.FileType.Directory ? '/' : '')); + return dirInfo; + } + + async #cmdCd(path?: string) { + if (!path) { + return this.#cmdPwd(); + } + try { + const dirUri = resolvePath(path, this.#cwd); + const s = await vscode.workspace.fs.stat(dirUri); + if (s.type & vscode.FileType.Directory) { + this.#cwd = dirUri; + } else { + throw vscode.FileSystemError.FileNotADirectory(dirUri); + } + } catch (e) { + if (e instanceof vscode.FileSystemError) { + this.log(`"${path}" is not a valid directory.`); + return; + } + this.log('Error: %o', e); + } + } + + async #cmdSuggestions(word: string | undefined, _options: { max?: number | undefined }) { + if (!word) return; + const result = await cmdSuggestions(word, this.#cwd); + this.log('%s', result); + } + + close = () => { + consoleDebug('Repl.close'); + if (this.#closed) return; + this.#closed = true; + this.#cancelationTokenSource?.cancel(); + this.#controller.abort(); + this.#rl?.close(); + this.#readStream.destroy(); + this.#writeStream.destroy(); + this.#emitterOnDidClose.fire(); + this.#cancelationTokenSource?.dispose(); + this.#cancelationTokenSource = undefined; + }; + + log: typeof console.log = (...args) => this.#output(crlf(formatWithOptions({ colors: true }, ...args) + '\n')); + error: typeof console.error = (...args) => this.#output(red('Error: ') + crlf(formatWithOptions({ colors: true }, ...args) + '\n')); + + #cancelAction = () => this.#cancelationTokenSource?.cancel(); + + #createCancelationTokenForAction(): vscode.CancellationToken { + return this.#createCancelationTokenSourceForAction().token; + } + + #getCancelationTokenForAction(): vscode.CancellationToken { + return this.#cancelationTokenSource?.token || this.#createCancelationTokenForAction(); + } + + #createCancelationTokenSourceForAction(): vscode.CancellationTokenSource { + this.#cancelationTokenSource?.dispose(); + const t = abortControllerToCancelationTokenSource(this.#controller); + this.#cancelationTokenSource = t; + return t; + } +} + +function abortControllerToCancelationTokenSource(ac: AbortController): vscode.CancellationTokenSource { + const t = new vscode.CancellationTokenSource(); + ac.signal.onabort = () => t.cancel(); + return t; +} + +function findLongestPrefix(values: string[]): string { + if (!values.length) return ''; + let val = values[0]; + for (const v of values) { + let i = 0; + for (; i < val.length && i < v.length; ++i) { + if (val[i] !== v[i]) break; + } + val = val.slice(0, i); + if (!val) return val; + } + return val; +} diff --git a/packages/client/src/repl/textUtils.mts b/packages/client/src/repl/textUtils.mts new file mode 100644 index 000000000..552edddc6 --- /dev/null +++ b/packages/client/src/repl/textUtils.mts @@ -0,0 +1,72 @@ +export function splitIntoLines(text: string, width: number): string[] { + const lines: string[] = []; + + const words = splitLinesKeepNewLine(text.replace(/\t/g, ' ')).flatMap((line) => line.split(/ /)); + + let w = 0; + let line = ''; + let space = ''; + for (const word of words) { + if (word === '\n' || word === '\r') { + lines.push(line); + line = ''; + space = ''; + w = 0; + continue; + } + if (line && w + space.length + word.length > width) { + lines.push(line); + line = ''; + space = ''; + w = 0; + } + line += space + word; + w += space.length + word.length; + space = ' '; + } + + lines.push(line); + + return lines.map((line) => line.trimEnd()); +} + +function _unindent(s: string) { + const lines = s.split('\n'); + const indents = lines + .map((line) => line.replace(/^\s+$/, '')) + .map((line) => line.replace(/^(\s*).*/, '$1').length) + .filter((n) => n > 0); + const minIndent = Math.min(...indents); + return lines.map((line) => line.slice(minIndent)).join('\n'); +} + +/** + * Moves all lines to the left by the minimum amount of leading whitespace found in any line. + * + * @param str - string to unindent + * @returns unindented string + */ +export function unindent(str: string): string; +/** + * Template function that unindents a string by the minimum amount of leading whitespace found in any line. + * + * Example: + * ```ts + * const usage = unindent`\ + * Usage: foo [options] + * `; + * ``` + * See: {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates MDN: Tagged Templates} + * @param strings - TemplateStringsArray + * @param values - values to interpolate + */ +export function unindent(strings: TemplateStringsArray, ...values: unknown[]): string; +export function unindent(strings: TemplateStringsArray | string, ...values: unknown[]): string { + return typeof strings === 'string' ? _unindent(strings) : _unindent(String.raw({ raw: strings }, ...values)); +} + +function splitLinesKeepNewLine(text: string): string[] { + text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = text.split('\n'); + return lines.flatMap((line) => [line, '\n']).slice(0, -1); +} diff --git a/packages/client/src/repl/textUtils.test.mts b/packages/client/src/repl/textUtils.test.mts new file mode 100644 index 000000000..1781e09f7 --- /dev/null +++ b/packages/client/src/repl/textUtils.test.mts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; + +import { splitIntoLines, unindent } from './textUtils.mjs'; + +describe('textUtils', () => { + test('splitIntoLines should split text into lines of specified width', () => { + const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; + const width = 10; + const expectedLines = ['Lorem', 'ipsum', 'dolor sit', 'amet,', 'consectetur', 'adipiscing', 'elit.']; + + const result = splitIntoLines(text, width); + + expect(result).toEqual(expectedLines); + }); + + test('splitIntoLines should honor \\n', () => { + const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\t- one\n\t- two\n'; + const width = 20; + const expectedLines = unindent(`\ + Lorem ipsum dolor + sit amet, + consectetur + adipiscing elit. + - one + - two + `).split('\n'); + + const result = splitIntoLines(text, width); + + expect(result).toEqual(expectedLines); + }); + test('splitIntoLines does NOT split words', () => { + const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; + const width = 1; + const expectedLines = ['Lorem', 'ipsum', 'dolor', 'sit', 'amet,', 'consectetur', 'adipiscing', 'elit.']; + + const result = splitIntoLines(text, width); + + expect(result).toEqual(expectedLines); + }); + + test('splitIntoLines should handle empty text', () => { + const text = ''; + const width = 5; + const expectedLines: string[] = ['']; + + const result = splitIntoLines(text, width); + + expect(result).toEqual(expectedLines); + }); + + // Add more test cases here... +}); + +describe('unindent', () => { + test('unindent should remove left padding from multi-line text', () => { + const input = `\ + This is a command with unnecessary complexity and options. + Even the description is long and verbose. with a lot of words and new lines. + `; + const expectedOutput = + 'This is a command with unnecessary complexity and options.\n' + + 'Even the description is long and verbose. with a lot of words and new lines.\n'; + + const result = unindent(input); + + expect(result).toBe(expectedOutput); + }); + + test('unindent should remove left padding from a template string.', () => { + const input = unindent`\ + This is a command with unnecessary complexity and options. + Even the description is long and verbose. with a lot of words and new lines. + `; + const expectedOutput = + 'This is a command with unnecessary complexity and options.\n' + + 'Even the description is long and verbose. with a lot of words and new lines.\n'; + + expect(input).toBe(expectedOutput); + }); +}); diff --git a/packages/client/src/util/errors.ts b/packages/client/src/util/errors.ts index 3e564c2f3..8f8d4ae7d 100644 --- a/packages/client/src/util/errors.ts +++ b/packages/client/src/util/errors.ts @@ -2,6 +2,7 @@ import { format } from 'util'; import { window } from 'vscode'; export function isError(e: unknown): e is Error { + if (e instanceof Error) return true; if (!e || typeof e !== 'object') return false; const err = e; return err.message !== undefined && err.name !== undefined; @@ -130,3 +131,12 @@ const canceledName = 'Canceled'; function isPromiseCanceledError(error: unknown): boolean { return error instanceof Error && error.name === canceledName && error.message === canceledName; } + +export function toError(e: unknown): Error { + if (isError(e)) { + return e; + } + const err = new Error(format('Error: %o', e)); + err.cause = e; + return err; +}