From 8eb654b0ee478c35254f4a2b08d9f4d1d0d81cfc Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Fri, 24 Apr 2026 12:34:41 -0700 Subject: [PATCH 1/4] Prevent formatter from throwing on malformed input with embed rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter parses with `ignoreErrors = true`, which can leave hidden `AstNodeError` nodes in a tree where `hasErrors()` still returns false. `resolveEmbedBlocks` called `root.toKsonValue()` directly, so those hidden errors surfaced as a `ShouldNotHappenException` that escaped `format()` instead of falling back to the original source. This only reproduced when embed block rules were configured. Take a `KsonValue` in `resolveEmbedBlocks` instead of a `KsonRoot`, and have the caller source it from `AstParseResult.ksonValue` — which already catches the conversion exception and returns null. The new `ksonValue != null` guard is strictly stronger than the previous `!hasErrors()` check. --- .../kotlin/org/kson/ast/EmbedBlockResolver.kt | 9 ++++----- src/commonMain/kotlin/org/kson/tools/Formatter.kt | 6 +++--- .../kotlin/org/kson/tools/FormatterTest.kt | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/commonMain/kotlin/org/kson/ast/EmbedBlockResolver.kt b/src/commonMain/kotlin/org/kson/ast/EmbedBlockResolver.kt index b84a82e1..584b3229 100644 --- a/src/commonMain/kotlin/org/kson/ast/EmbedBlockResolver.kt +++ b/src/commonMain/kotlin/org/kson/ast/EmbedBlockResolver.kt @@ -2,8 +2,8 @@ package org.kson.ast import org.kson.tools.InternalEmbedRule import org.kson.value.KsonString +import org.kson.value.KsonValue import org.kson.value.navigation.json_pointer.ExperimentalJsonPointerGlobLanguage -import org.kson.value.toKsonValue import org.kson.walker.KsonValueWalker import org.kson.walker.navigateWithJsonPointerGlob @@ -23,23 +23,22 @@ data class EmbedBlockResolution( /** * Resolves which string nodes should be formatted as embed blocks based on the provided rules. * - * This function pre-processes the AST to build a map of StringNodes to their matching embed rules, + * This function pre-processes the parsed value to build a map of StringNodes to their matching embed rules, * eliminating the need to thread path context through the serialization process. * * Only StringNode instances need tracking - EmbedBlockNode instances are already embed blocks * and will format correctly using their existing tags. * - * @param root The root of the AST to process + * @param rootValue The root [KsonValue] to process * @param rules The embed block rules to match against document paths * @return An EmbedBlockResolution containing the map of StringNodes to their matching rules */ @OptIn(ExperimentalJsonPointerGlobLanguage::class) fun resolveEmbedBlocks( - root: KsonRoot, + rootValue: KsonValue, rules: List ): EmbedBlockResolution { if (rules.isEmpty()) return EmbedBlockResolution.EMPTY - val rootValue = root.toKsonValue() val stringResult = mutableMapOf() for (rule in rules) { val matchingValues = KsonValueWalker.navigateWithJsonPointerGlob(rootValue, rule.pathPattern) diff --git a/src/commonMain/kotlin/org/kson/tools/Formatter.kt b/src/commonMain/kotlin/org/kson/tools/Formatter.kt index d1e94ba1..dac9b389 100644 --- a/src/commonMain/kotlin/org/kson/tools/Formatter.kt +++ b/src/commonMain/kotlin/org/kson/tools/Formatter.kt @@ -24,9 +24,9 @@ fun format(ksonSource: String, formatterConfig: KsonFormatterConfig = KsonFormat val astParseResult = parseToAst(ksonSource, CoreCompileConfig(ignoreErrors = true)) - // Pre-process: find all nodes that should be formatted as embed blocks - val embedBlockResolution = if (formatterConfig.embedBlockRules.isNotEmpty() && !astParseResult.hasErrors()) { - resolveEmbedBlocks(astParseResult.ast, formatterConfig.embedBlockRules) + val ksonValue = astParseResult.ksonValue + val embedBlockResolution = if (formatterConfig.embedBlockRules.isNotEmpty() && ksonValue != null) { + resolveEmbedBlocks(ksonValue, formatterConfig.embedBlockRules) } else { EmbedBlockResolution.EMPTY } diff --git a/src/commonTest/kotlin/org/kson/tools/FormatterTest.kt b/src/commonTest/kotlin/org/kson/tools/FormatterTest.kt index f6324b81..255dbfc1 100644 --- a/src/commonTest/kotlin/org/kson/tools/FormatterTest.kt +++ b/src/commonTest/kotlin/org/kson/tools/FormatterTest.kt @@ -2368,4 +2368,18 @@ class FormatterTest { embedBlockRules = listOf(embedRule("/scripts/build")), ) } + + @Test + fun testMalformedInputWithEmbedRulesDoesNotThrow() { + // Regression: format() used to throw ShouldNotHappenException when the AST contained hidden + // AstNodeError nodes (parsed with ignoreErrors = true) while embed block rules were active. + val malformedInput = "version: v1name: namepipeline: g1: tasks: task1:" + val withRules = format( + malformedInput, + KsonFormatterConfig(embedBlockRules = listOf(embedRule("/pipeline/**/tasks/*"))) + ) + val withoutRules = format(malformedInput, KsonFormatterConfig()) + // With unrecoverable parse state, the embed rules fall back to a no-op — output matches the rule-free path. + assertEquals(withoutRules, withRules) + } } From 79a0915f9d21defc4090e54cfa6592c798b07e7b Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Thu, 23 Apr 2026 11:58:15 -0700 Subject: [PATCH 2/4] Read VSCode extension namespace from the manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream kson extension hard-coded "kson" as the prefix for its contributed command ids (kson.selectSchema) and configuration properties (kson.format.*, kson.codeLens.enable, kson.trace.server, ...). The client now reads the namespace from a `ksonConfigNamespace` top-level manifest field and then "kson", and passes it to the server via initializationOptions. CommandType enum values are unqualified ids (plainFormat, compactFormat, ...) with the wire prefix applied at the LSP boundary via toWireCommandId / fromWireCommandId — both exported so the client doesn't hand-build wire ids either. KsonSettings is flattened to drop the fixed { kson: {...} } wrapper and a single unwrapPushedSettings helper handles both namespaced and already-scoped push payloads. --- .../src/core/KsonSettings.ts | 34 ++++--- .../src/core/commands/CommandExecutor.base.ts | 2 +- .../src/core/commands/CommandType.ts | 93 ++++++++++++------- .../src/core/features/DiagnosticService.ts | 31 ++++--- .../core/services/KsonTextDocumentService.ts | 26 ++++-- tooling/language-server-protocol/src/index.ts | 1 + .../src/startKsonServer.ts | 43 +++++---- .../test/core/commands/CommandType.test.ts | 34 +++++++ .../core/features/FormattingService.test.ts | 17 ++-- .../services/KsonTextDocumentService.test.ts | 2 +- .../src/client/browser/ksonClientMain.ts | 8 +- .../src/client/common/StatusBarManager.ts | 4 +- .../vscode/src/client/node/ksonClientMain.ts | 15 +-- .../vscode/src/config/bundledSchemaLoader.ts | 4 +- .../vscode/src/config/languageConfig.ts | 35 ++++++- .../vscode/test/suite/language-config.test.ts | 25 ++++- 16 files changed, 262 insertions(+), 112 deletions(-) create mode 100644 tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts diff --git a/tooling/language-server-protocol/src/core/KsonSettings.ts b/tooling/language-server-protocol/src/core/KsonSettings.ts index 00595019..b23a0c0a 100644 --- a/tooling/language-server-protocol/src/core/KsonSettings.ts +++ b/tooling/language-server-protocol/src/core/KsonSettings.ts @@ -2,23 +2,29 @@ import {LSPAny} from "vscode-languageserver"; import {FormatOptions, FormattingStyle, IndentType} from "kson"; /** - * Configuration settings for the Kson language server + * Configuration settings for the Kson language server. + * + * These are the already-unwrapped settings — the server has stripped the + * configuration namespace (e.g. "kson") before calling + * {@link ksonSettingsWithDefaults}. */ export interface KsonSettings { - kson: { - formatOptions: FormatOptions; - codeLensEnabled: boolean; - }; + formatOptions: FormatOptions; + codeLensEnabled: boolean; } /** * Create new [KsonSettings] from LSP settings, applying defaults where needed. + * + * Input is the object returned by `connection.workspace.getConfiguration(ns)`, + * so the shape is `{ format?: {...}, codeLens?: {...} }` without any outer + * namespace key. */ export function ksonSettingsWithDefaults(settings?: LSPAny): Required { // Create IndentType based on the provided settings let indentType: IndentType; - if (settings?.kson?.format) { - const format = settings.kson.format; + if (settings?.format) { + const format = settings.format; if (format.insertSpaces === false) { indentType = IndentType.Tabs; } else { @@ -33,9 +39,9 @@ export function ksonSettingsWithDefaults(settings?: LSPAny): Required toDiagnostic(msg)); + return messages.map(msg => this.toDiagnostic(msg)); } -} -function toDiagnostic(msg: DiagnosticMessage): Diagnostic { - return { - range: { - start: {line: msg.range.startLine, character: msg.range.startColumn}, - end: {line: msg.range.endLine, character: msg.range.endColumn} - }, - severity: msg.severity === KtSeverity.ERROR - ? DiagnosticSeverity.Error - : DiagnosticSeverity.Warning, - source: 'kson', - message: msg.message - }; + private toDiagnostic(msg: DiagnosticMessage): Diagnostic { + return { + range: { + start: {line: msg.range.startLine, character: msg.range.startColumn}, + end: {line: msg.range.endLine, character: msg.range.endColumn} + }, + severity: msg.severity === KtSeverity.ERROR + ? DiagnosticSeverity.Error + : DiagnosticSeverity.Warning, + source: this.configNamespace, + message: msg.message + }; + } } diff --git a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts index 7a8bc7bc..3c05e409 100644 --- a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts +++ b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts @@ -38,6 +38,11 @@ import {FoldingRangeService} from '../features/FoldingRangeService.js'; import {SelectionRangeService} from '../features/SelectionRangeService.js'; import {CommandExecutorBase} from '../commands/CommandExecutor.base.js'; import { CommandExecutorFactory } from '../commands/CommandExecutorFactory.js'; +import { + DEFAULT_CONFIG_NAMESPACE, + fromWireCommandId, + toWireCommandId, +} from '../commands/CommandType.js'; import {KsonSettings, ksonSettingsWithDefaults} from '../KsonSettings.js'; @@ -64,10 +69,11 @@ export class KsonTextDocumentService { constructor( private documentManager: KsonDocumentsManager, private createCommandExecutor: CommandExecutorFactory, - private workspaceRoot: string | null = null + private workspaceRoot: string | null = null, + private configNamespace: string = DEFAULT_CONFIG_NAMESPACE ) { this.formattingService = new FormattingService(); - this.diagnosticService = new DiagnosticService(); + this.diagnosticService = new DiagnosticService(configNamespace); this.semanticTokensService = new SemanticTokensService(); this.codeLensService = new CodeLensService(); this.documentHighlightService = new DocumentHighlightService(); @@ -158,7 +164,7 @@ export class KsonTextDocumentService { if (!document) { return []; } - return this.formattingService.formatDocument(document, this.configuration.kson.formatOptions); + return this.formattingService.formatDocument(document, this.configuration.formatOptions); } catch (error) { this.connection.console.error(`Error formatting document: ${error}`); return []; @@ -183,14 +189,19 @@ export class KsonTextDocumentService { private async onCodeLens(params: CodeLensParams): Promise { try { - if (!this.configuration.kson.codeLensEnabled) { + if (!this.configuration.codeLensEnabled) { return []; } const document = this.documentManager.get(params.textDocument.uri); if (!document) { return []; } - return this.codeLensService.getCodeLenses(document); + return this.codeLensService.getCodeLenses(document).map(lens => ({ + ...lens, + command: lens.command + ? {...lens.command, command: toWireCommandId(lens.command.command, this.configNamespace)} + : lens.command, + })); } catch (error) { this.connection.console.error(`Error providing code lenses: ${error}`); return []; @@ -199,7 +210,10 @@ export class KsonTextDocumentService { private async onExecuteCommand(params: ExecuteCommandParams): Promise { try { - return await this.commandExecutor.execute(params); + const commandType = fromWireCommandId(params.command, this.configNamespace); + return await this.commandExecutor.execute( + commandType ? {...params, command: commandType} : params + ); } catch (error) { this.connection.console.error(`Error executing command: ${error}`); this.connection.window.showErrorMessage(`Command execution failed: ${error}`); diff --git a/tooling/language-server-protocol/src/index.ts b/tooling/language-server-protocol/src/index.ts index 3a47a1ff..b80d5efd 100644 --- a/tooling/language-server-protocol/src/index.ts +++ b/tooling/language-server-protocol/src/index.ts @@ -1,3 +1,4 @@ // Common exports for kson-language-server package export type { BundledSchemaConfig, BundledMetaSchemaConfig } from './core/schema/BundledSchemaProvider.js'; export type { KsonInitializationOptions } from './startKsonServer.js'; +export { CommandType, DEFAULT_CONFIG_NAMESPACE, toWireCommandId } from './core/commands/CommandType.js'; diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 1b188503..c17d76ca 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -11,7 +11,7 @@ import {KsonDocumentsManager} from './core/document/KsonDocumentsManager.js'; import {isKsonSchemaDocument} from './core/document/KsonSchemaDocument.js'; import {KsonTextDocumentService} from './core/services/KsonTextDocumentService.js'; import {KSON_LEGEND} from './core/features/SemanticTokensService.js'; -import {getAllCommandIds} from './core/commands/CommandType.js'; +import {DEFAULT_CONFIG_NAMESPACE, getAllCommandIds, toWireCommandId} from './core/commands/CommandType.js'; import { ksonSettingsWithDefaults } from './core/KsonSettings.js'; import {SchemaProvider} from './core/schema/SchemaProvider.js'; import {BundledSchemaProvider, BundledSchemaConfig, BundledMetaSchemaConfig} from './core/schema/BundledSchemaProvider.js'; @@ -29,6 +29,13 @@ export interface KsonInitializationOptions { bundledMetaSchemas?: BundledMetaSchemaConfig[]; /** Whether bundled schemas are enabled */ enableBundledSchemas?: boolean; + /** + * Prefix for the client's configuration keys (defaults to "kson"). The + * server uses this when pulling settings from the client, so a client + * built under a different namespace reads `.format.*` / + * `.codeLens.*` instead of the base `kson.*`. + */ + configNamespace?: string; } type SchemaProviderFactory = ( @@ -51,6 +58,7 @@ export function startKsonServer( // Variables to store state during initialization let workspaceRootUri: URI | undefined; let hasConfigurationCapability = false; + let configNamespace = DEFAULT_CONFIG_NAMESPACE; // Create logger that uses the connection const logger = { @@ -80,6 +88,7 @@ export function startKsonServer( const bundledSchemas = initOptions?.bundledSchemas ?? []; const bundledMetaSchemas = initOptions?.bundledMetaSchemas ?? []; const enableBundledSchemas = initOptions?.enableBundledSchemas ?? true; + configNamespace = initOptions?.configNamespace ?? DEFAULT_CONFIG_NAMESPACE; // Create the appropriate schema provider for this environment (file system or no-op) const fileSystemSchemaProvider = await createSchemaProvider(workspaceRootUri, logger); @@ -116,7 +125,8 @@ export function startKsonServer( textDocumentService = new KsonTextDocumentService( documentManager, createCommandExecutor, - workspaceRoot + workspaceRoot, + configNamespace ); // Setup document handling and connect services @@ -138,7 +148,7 @@ export function startKsonServer( // Diagnostics (pull model preferred) diagnosticProvider: { - identifier: 'kson', + identifier: configNamespace, interFileDependencies: false, workspaceDiagnostics: false } as DiagnosticRegistrationOptions, @@ -148,9 +158,11 @@ export function startKsonServer( resolveProvider: false }, - // Execute command + // Execute command — advertise ids under the active namespace so a + // client built under a non-default namespace doesn't collide with + // the base `kson.*` registrations in VSCode's global command registry. executeCommandProvider: { - commands: getAllCommandIds() + commands: getAllCommandIds().map(id => toWireCommandId(id, configNamespace)) }, // Folding ranges @@ -189,8 +201,8 @@ export function startKsonServer( // Pull configuration from the client async function pullConfiguration(): Promise { - const settings = await connection.workspace.getConfiguration('kson'); - const configuration = ksonSettingsWithDefaults({ kson: settings }); + const settings = await connection.workspace.getConfiguration(configNamespace); + const configuration = ksonSettingsWithDefaults(settings); textDocumentService.updateConfiguration(configuration); } @@ -199,7 +211,7 @@ export function startKsonServer( if (hasConfigurationCapability) { // Register for configuration change notifications connection.client.register(DidChangeConfigurationNotification.type, { - section: 'kson' + section: configNamespace }); // Pull initial configuration await pullConfiguration(); @@ -271,21 +283,20 @@ export function startKsonServer( } }); - // Handle configuration changes + // Handle configuration changes. Per LSP, the push model sends the full + // settings object, so our slice arrives at `change.settings.`. connection.onDidChangeConfiguration(async (change) => { + const scoped = change.settings?.[configNamespace]; + if (hasConfigurationCapability) { // Pull configuration from the client (pull model) await pullConfiguration(); } else { - // Fallback to reading pushed settings - const configuration = ksonSettingsWithDefaults(change.settings); - textDocumentService.updateConfiguration(configuration); + textDocumentService.updateConfiguration(ksonSettingsWithDefaults(scoped)); } - // Check if bundled schema setting changed - if (bundledSchemaProvider && change.settings?.kson?.enableBundledSchemas !== undefined) { - const enabled = change.settings.kson.enableBundledSchemas; - bundledSchemaProvider.setEnabled(enabled); + if (bundledSchemaProvider && scoped?.enableBundledSchemas !== undefined) { + bundledSchemaProvider.setEnabled(scoped.enableBundledSchemas); notifySchemaChange(); } diff --git a/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts b/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts new file mode 100644 index 00000000..d8de978f --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts @@ -0,0 +1,34 @@ +import {describe, it} from 'mocha'; +import assert from 'assert'; +import { + CommandType, + DEFAULT_CONFIG_NAMESPACE, + fromWireCommandId, + getAllCommandIds, + toWireCommandId, +} from '../../../core/commands/CommandType.js'; + +describe('CommandType wire-namespace translation', () => { + it('toWireCommandId / fromWireCommandId round-trip every CommandType under any namespace', () => { + for (const ns of [DEFAULT_CONFIG_NAMESPACE, 'config', 'a.b']) { + for (const id of getAllCommandIds()) { + const wire = toWireCommandId(id, ns); + assert.strictEqual(wire, `${ns}.${id}`); + assert.strictEqual(fromWireCommandId(wire, ns), id); + } + } + }); + + it('fromWireCommandId returns undefined for unknown ids or wrong namespace', () => { + assert.strictEqual(fromWireCommandId('config.bogus', 'config'), undefined); + assert.strictEqual(fromWireCommandId('other.plainFormat', 'config'), undefined); + assert.strictEqual(fromWireCommandId('plainFormat', 'config'), undefined); + }); + + it('CommandType values are unqualified (no dot)', () => { + for (const id of getAllCommandIds()) { + assert.ok(!id.includes('.'), `CommandType value '${id}' must not contain a dot`); + } + assert.strictEqual(CommandType.PLAIN_FORMAT, 'plainFormat'); + }); +}); diff --git a/tooling/language-server-protocol/src/test/core/features/FormattingService.test.ts b/tooling/language-server-protocol/src/test/core/features/FormattingService.test.ts index 7245cf81..6ff60dc8 100644 --- a/tooling/language-server-protocol/src/test/core/features/FormattingService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/FormattingService.test.ts @@ -22,19 +22,14 @@ describe('KSON Formatter', () => { KsonTooling.getInstance().parse(unformatted), ); - const ksonSettings = ksonSettingsWithDefaults( - { - kson: - { - format: { - insertSpaces: insertSpaces, - tabSize: 2 - } - } + const ksonSettings = ksonSettingsWithDefaults({ + format: { + insertSpaces: insertSpaces, + tabSize: 2 } - ) + }) - const edits = formattingService.formatDocument(ksonDocument, ksonSettings.kson.formatOptions); + const edits = formattingService.formatDocument(ksonDocument, ksonSettings.formatOptions); const formatted = applyEdits(document, edits); assert.strictEqual(formatted, expected, 'should have a matching formatted document'); diff --git a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts index bfa93806..2e00e64a 100644 --- a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts +++ b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts @@ -127,7 +127,7 @@ describe('KsonTextDocumentService', () => { it('should return empty array when codeLens is disabled', async () => { openDocument('key: value'); - const config = ksonSettingsWithDefaults({kson: {codeLens: {enable: false}}}); + const config = ksonSettingsWithDefaults({codeLens: {enable: false}}); service.updateConfiguration(config); const result = await connection.requestCodeLens(TEST_URI); diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index cd17138f..54a03948 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/browser'; import { createClientOptions } from '../../config/clientOptions'; -import { initializeLanguageConfig } from '../../config/languageConfig'; +import { initializeLanguageConfig, getConfigNamespace } from '../../config/languageConfig'; import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; @@ -12,6 +12,7 @@ import { registerBundledSchemaContentProvider } from '../common/BundledSchemaCon export async function activate(context: vscode.ExtensionContext) { // Initialize language configuration from package.json initializeLanguageConfig(context.extension.packageJSON); + const configNamespace = getConfigNamespace(); // Create log output channel const logOutputChannel = vscode.window.createOutputChannel('Kson Language Server', { log: true }); @@ -35,7 +36,8 @@ export async function activate(context: vscode.ExtensionContext) { const clientOptions = createClientOptions(logOutputChannel, { bundledSchemas, bundledMetaSchemas, - enableBundledSchemas: areBundledSchemasEnabled() + enableBundledSchemas: areBundledSchemasEnabled(), + configNamespace }); // In test environments, we need to support the vscode-test-web scheme @@ -53,7 +55,7 @@ export async function activate(context: vscode.ExtensionContext) { } const languageClient = new LanguageClient( - 'kson-browser', + `${configNamespace}-browser`, 'KSON Language Server (Browser)', clientOptions, worker diff --git a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index ec95e1f0..719af40c 100644 --- a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts +++ b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import {BaseLanguageClient} from 'vscode-languageclient'; import * as path from 'path'; -import { isKsonLanguage } from '../../config/languageConfig'; +import { isKsonLanguage, getConfigNamespace } from '../../config/languageConfig'; /** * Response from the LSP server for schema information @@ -26,7 +26,7 @@ export class StatusBarManager { vscode.StatusBarAlignment.Right, 100 ); - this.statusBarItem.command = 'kson.selectSchema'; + this.statusBarItem.command = `${getConfigNamespace()}.selectSchema`; } /** diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index e56bf699..2e017a23 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -10,9 +10,10 @@ import { createClientOptions } from '../../config/clientOptions'; import {StatusBarManager} from '../common/StatusBarManager'; -import { isKsonLanguage, initializeLanguageConfig } from '../../config/languageConfig'; +import { isKsonLanguage, initializeLanguageConfig, getConfigNamespace } from '../../config/languageConfig'; import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; +import { CommandType, toWireCommandId } from 'kson-language-server'; /** * Node.js-specific activation function for the KSON extension. @@ -21,6 +22,7 @@ import { registerBundledSchemaContentProvider } from '../common/BundledSchemaCon export async function activate(context: vscode.ExtensionContext) { // Initialize language configuration from package.json initializeLanguageConfig(context.extension.packageJSON); + const configNamespace = getConfigNamespace(); // Create log output channel const logOutputChannel = vscode.window.createOutputChannel('Kson Language Server', {log: true}); @@ -53,9 +55,10 @@ export async function activate(context: vscode.ExtensionContext) { const clientOptions: LanguageClientOptions = createClientOptions(logOutputChannel, { bundledSchemas, bundledMetaSchemas, - enableBundledSchemas: areBundledSchemasEnabled() + enableBundledSchemas: areBundledSchemasEnabled(), + configNamespace }); - const languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) + const languageClient = new LanguageClient(configNamespace, serverOptions, clientOptions, false) await languageClient.start(); @@ -85,7 +88,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register the schema selection command context.subscriptions.push( - vscode.commands.registerCommand('kson.selectSchema', async () => { + vscode.commands.registerCommand(`${configNamespace}.selectSchema`, async () => { const editor = vscode.window.activeTextEditor; if (!editor || !isKsonLanguage(editor.document.languageId)) { vscode.window.showWarningMessage('Please open a KSON file first.'); @@ -133,7 +136,7 @@ export async function activate(context: vscode.ExtensionContext) { // Execute the remove schema command via LSP try { await languageClient.sendRequest('workspace/executeCommand', { - command: 'kson.removeSchema', + command: toWireCommandId(CommandType.REMOVE_SCHEMA, configNamespace), arguments: [{ documentUri: editor.document.uri.toString() }] @@ -151,7 +154,7 @@ export async function activate(context: vscode.ExtensionContext) { try { // Execute the associate schema command via LSP await languageClient.sendRequest('workspace/executeCommand', { - command: 'kson.associateSchema', + command: toWireCommandId(CommandType.ASSOCIATE_SCHEMA, configNamespace), arguments: [{ documentUri: editor.document.uri.toString(), schemaPath: schemaPath diff --git a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts index 7d8f0110..9f6ad589 100644 --- a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts +++ b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { getLanguageConfiguration, BundledSchemaMapping } from './languageConfig'; +import { getLanguageConfiguration, getConfigNamespace, BundledSchemaMapping } from './languageConfig'; import type { BundledSchemaConfig, BundledMetaSchemaConfig } from 'kson-language-server'; export type { BundledSchemaConfig, BundledMetaSchemaConfig }; @@ -116,6 +116,6 @@ async function loadSchemaFile( * Check if bundled schemas are enabled via VS Code settings. */ export function areBundledSchemasEnabled(): boolean { - const config = vscode.workspace.getConfiguration('kson'); + const config = vscode.workspace.getConfiguration(getConfigNamespace()); return config.get('enableBundledSchemas', true); } diff --git a/tooling/lsp-clients/vscode/src/config/languageConfig.ts b/tooling/lsp-clients/vscode/src/config/languageConfig.ts index 805a7543..b2b9c2b6 100644 --- a/tooling/lsp-clients/vscode/src/config/languageConfig.ts +++ b/tooling/lsp-clients/vscode/src/config/languageConfig.ts @@ -1,3 +1,5 @@ +import { DEFAULT_CONFIG_NAMESPACE } from 'kson-language-server'; + /** * Configuration for bundled schemas mapped by file extension. */ @@ -13,8 +15,27 @@ export interface LanguageConfiguration { fileExtensions: string[]; /** Bundled schema mappings extracted from package.json */ bundledSchemas: BundledSchemaMapping[]; + /** + * Prefix for VSCode commands and configuration keys (e.g. "kson"). + * Read from {@link CONFIG_NAMESPACE_MANIFEST_FIELD}, defaulting to "kson". + */ + configNamespace: string; } +/** + * Root-level package.json field naming the namespace for VSCode commands and + * configuration keys. A build toolchain that produces a non-default extension + * (e.g. a derived one using a different `languageId`) sets this so the derived + * extension doesn't collide with the base kson extension on install. + * + * A derived extension's manifest must keep three things in sync: (1) set + * `ksonConfigNamespace` to the new namespace; (2) rewrite static contribution + * keys — `contributes.commands[].command` ids and `contributes.configuration.properties` + * keys — to live under that prefix instead of `kson.*`. Runtime call sites all + * route through {@link getConfigNamespace}, so no source changes are required. + */ +const CONFIG_NAMESPACE_MANIFEST_FIELD = 'ksonConfigNamespace'; + let cachedConfig: LanguageConfiguration | null = null; /** @@ -34,6 +55,15 @@ export function isKsonLanguage(languageId: string): boolean { return getLanguageConfiguration().languageIds.includes(languageId); } +/** + * Namespace prefix for this extension's commands and configuration keys at + * runtime. Contribution keys in package.json (commands, configuration + * properties) are static and must be rewritten separately when forking. + */ +export function getConfigNamespace(): string { + return getLanguageConfiguration().configNamespace; +} + /** * Initialize language configuration from extension's package.json. * Call this early in the activate function. @@ -55,7 +85,10 @@ export function initializeLanguageConfig(packageJson: any): void { .flatMap((lang: any) => lang.extensions || []) .filter(Boolean) .map((ext: string) => ext.replace(/^\./, '')), - bundledSchemas + bundledSchemas, + configNamespace: + packageJson?.[CONFIG_NAMESPACE_MANIFEST_FIELD] + || DEFAULT_CONFIG_NAMESPACE }; } diff --git a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index 5218e1be..3236a200 100644 --- a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts @@ -1,5 +1,5 @@ import { assert } from './assert'; -import { initializeLanguageConfig, getLanguageConfiguration, isKsonLanguage, resetLanguageConfiguration } from '../../src/config/languageConfig'; +import { initializeLanguageConfig, getLanguageConfiguration, getConfigNamespace, isKsonLanguage, resetLanguageConfiguration } from '../../src/config/languageConfig'; describe('Language Configuration Tests', () => { @@ -117,4 +117,27 @@ describe('Language Configuration Tests', () => { assert.ok(config.bundledSchemas.some(s => s.fileExtension === 'config')); }); }); + + describe('configNamespace', () => { + it('Should default to "kson" when no languages are declared and no field is set', () => { + initWithLanguages([]); + assert.strictEqual(getConfigNamespace(), 'kson'); + }); + + it('Should use the ksonConfigNamespace manifest field when present', () => { + initializeLanguageConfig({ + ksonConfigNamespace: 'config', + contributes: { languages: [{ id: 'config', extensions: ['.config'] }] } + }); + assert.strictEqual(getConfigNamespace(), 'config'); + }); + + it('Should prefer the manifest field over languages[0].id', () => { + initializeLanguageConfig({ + ksonConfigNamespace: 'config', + contributes: { languages: [{ id: 'different', extensions: ['.different'] }] } + }); + assert.strictEqual(getConfigNamespace(), 'config'); + }); + }) }); From 2c910ef4416858f63cd102d44963368943c40030 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 5 May 2026 19:27:33 +0200 Subject: [PATCH 3/4] Require ksonConfigNamespace manifest field Make the VSCode extension's package.json the single source of truth for the command/config namespace prefix. The base manifest now declares "ksonConfigNamespace": "kson" explicitly, and the client-side reader throws when the field is missing instead of silently falling back to a default. A fork that rebrands the extension can no longer half-configure itself by setting the contribution strings without updating the namespace declaration. Tighten the same contract on the LSP side: DiagnosticService and KsonTextDocumentService now require the namespace at construction (no implicit kson default), tests assert the contract ("source equals the configured namespace") rather than the literal value, and DEFAULT_CONFIG_NAMESPACE is no longer re-exported on the public package API since the manifest is now the only sanctioned source. The LSP server's initializationOptions fallback stays in place since arbitrary LSP clients can't be expected to send it. --- .../src/core/features/DiagnosticService.ts | 3 +-- .../core/services/KsonTextDocumentService.ts | 3 +-- tooling/language-server-protocol/src/index.ts | 2 +- .../src/startKsonServer.ts | 2 +- .../core/commands/CommandExecutor.test.ts | 8 ++++--- .../core/features/DiagnosticService.test.ts | 13 ++++++----- .../services/KsonTextDocumentService.test.ts | 5 +++-- tooling/lsp-clients/vscode/package.json | 1 + .../vscode/src/config/languageConfig.ts | 22 ++++++++++++------- .../vscode/test/suite/language-config.test.ts | 14 ++++++++---- 10 files changed, 44 insertions(+), 29 deletions(-) diff --git a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts index 3f7b69b3..f2f0a37f 100644 --- a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts +++ b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts @@ -7,7 +7,6 @@ import { import {KsonDocument} from '../document/KsonDocument'; import {isKsonSchemaDocument} from '../document/KsonSchemaDocument'; import {KsonTooling, DiagnosticMessage, DiagnosticSeverity as KtSeverity} from 'kson-tooling'; -import {DEFAULT_CONFIG_NAMESPACE} from '../commands/CommandType.js'; /** * Service responsible for providing diagnostic information for Kson documents. @@ -15,7 +14,7 @@ import {DEFAULT_CONFIG_NAMESPACE} from '../commands/CommandType.js'; */ export class DiagnosticService { - constructor(private readonly configNamespace: string = DEFAULT_CONFIG_NAMESPACE) {} + constructor(private readonly configNamespace: string) {} createDocumentDiagnosticReport(document: KsonDocument | null | undefined): DocumentDiagnosticReport { const diagnostics = document ? this.getDiagnostics(document) : []; diff --git a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts index 3c05e409..c78c6c10 100644 --- a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts +++ b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts @@ -39,7 +39,6 @@ import {SelectionRangeService} from '../features/SelectionRangeService.js'; import {CommandExecutorBase} from '../commands/CommandExecutor.base.js'; import { CommandExecutorFactory } from '../commands/CommandExecutorFactory.js'; import { - DEFAULT_CONFIG_NAMESPACE, fromWireCommandId, toWireCommandId, } from '../commands/CommandType.js'; @@ -70,7 +69,7 @@ export class KsonTextDocumentService { private documentManager: KsonDocumentsManager, private createCommandExecutor: CommandExecutorFactory, private workspaceRoot: string | null = null, - private configNamespace: string = DEFAULT_CONFIG_NAMESPACE + private configNamespace: string ) { this.formattingService = new FormattingService(); this.diagnosticService = new DiagnosticService(configNamespace); diff --git a/tooling/language-server-protocol/src/index.ts b/tooling/language-server-protocol/src/index.ts index b80d5efd..84d87aef 100644 --- a/tooling/language-server-protocol/src/index.ts +++ b/tooling/language-server-protocol/src/index.ts @@ -1,4 +1,4 @@ // Common exports for kson-language-server package export type { BundledSchemaConfig, BundledMetaSchemaConfig } from './core/schema/BundledSchemaProvider.js'; export type { KsonInitializationOptions } from './startKsonServer.js'; -export { CommandType, DEFAULT_CONFIG_NAMESPACE, toWireCommandId } from './core/commands/CommandType.js'; +export { CommandType, toWireCommandId } from './core/commands/CommandType.js'; diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index c17d76ca..122c5503 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -58,7 +58,7 @@ export function startKsonServer( // Variables to store state during initialization let workspaceRootUri: URI | undefined; let hasConfigurationCapability = false; - let configNamespace = DEFAULT_CONFIG_NAMESPACE; + let configNamespace: string; // Create logger that uses the connection const logger = { diff --git a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts index 29627462..f607dcac 100644 --- a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts +++ b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts @@ -11,7 +11,7 @@ import { TextEdit, ApplyWorkspaceEditResult, Range } from "vscode-languageserver"; -import {CommandType} from "../../../core/commands/CommandType"; +import {CommandType, toWireCommandId} from "../../../core/commands/CommandType"; import {FormattingStyle} from "kson"; import {RemoteWorkspace} from "vscode-languageserver/lib/common/server"; import {createCommandExecutor} from "../../../core/commands/createCommandExecutor.node.js"; @@ -35,10 +35,12 @@ class WorkspaceConnectionStub extends ConnectionStub { } } +const TEST_NAMESPACE = 'test-ns'; + function createTestSetup() { const connection = new ConnectionStub(); const documentsManager = new KsonDocumentsManager(); - const service = new KsonTextDocumentService(documentsManager, createCommandExecutor); + const service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_NAMESPACE); documentsManager.listen(connection); service.connect(connection); @@ -76,7 +78,7 @@ function buildWorkspaceEdit(uri: string, replaceRange: Range, newText: string): function buildCommandParams(command: CommandType, uri: string, style: FormattingStyle): ExecuteCommandParams { return { - command, + command: toWireCommandId(command, TEST_NAMESPACE), arguments: [{ documentUri: uri, formattingStyle: style }] }; } diff --git a/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts b/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts index 714dd610..8b7c0d18 100644 --- a/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts @@ -10,7 +10,8 @@ import {createKsonDocument} from '../../TestHelpers.js'; describe('KSON Diagnostics', () => { - const diagnosticService = new DiagnosticService(); + const TEST_NAMESPACE = 'test-ns'; + const diagnosticService = new DiagnosticService(TEST_NAMESPACE); function getDiagnostics(content: string, schemaContent?: string): Diagnostic[] { const ksonDocument = createKsonDocument(content, schemaContent); @@ -29,7 +30,7 @@ describe('KSON Diagnostics', () => { it('should report error for empty document', () => { const diagnostics = assertDiagnosticCount('', 1); assert.strictEqual(diagnostics[0].severity, DiagnosticSeverity.Error); - assert.strictEqual(diagnostics[0].source, 'kson'); + assert.strictEqual(diagnostics[0].source, TEST_NAMESPACE); }); it('should report no diagnostics for valid document', () => { @@ -66,10 +67,10 @@ describe('KSON Diagnostics', () => { assert.strictEqual(diagnostics[1].severity, DiagnosticSeverity.Warning); }); - it('should set source to kson on all diagnostics', () => { + it('should set source to the configured namespace on all diagnostics', () => { const diagnostics = getDiagnostics(''); for (const d of diagnostics) { - assert.strictEqual(d.source, 'kson'); + assert.strictEqual(d.source, TEST_NAMESPACE); } }); @@ -135,7 +136,7 @@ describe('KSON Diagnostics', () => { // Should still return at least the document parse errors, not crash assert.ok(diagnostics.length > 0, 'Should return document parse errors even when schema is invalid'); for (const d of diagnostics) { - assert.strictEqual(d.source, 'kson'); + assert.strictEqual(d.source, TEST_NAMESPACE); } }); @@ -153,7 +154,7 @@ describe('KSON Diagnostics', () => { assert.ok(diagnostics.length > 0); // All diagnostics should come from parse errors only for (const d of diagnostics) { - assert.strictEqual(d.source, 'kson'); + assert.strictEqual(d.source, TEST_NAMESPACE); } }); diff --git a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts index 2e00e64a..b1d03bec 100644 --- a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts +++ b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts @@ -15,6 +15,7 @@ import {ksonSettingsWithDefaults} from "../../../core/KsonSettings.js"; import {pos} from "../../TestHelpers"; describe('KsonTextDocumentService', () => { + const TEST_NAMESPACE = 'test-ns'; let connection: ConnectionStub; let service: KsonTextDocumentService; let documentsManager: KsonDocumentsManager; @@ -23,7 +24,7 @@ describe('KsonTextDocumentService', () => { beforeEach(() => { connection = new ConnectionStub(); documentsManager = new KsonDocumentsManager(); - service = new KsonTextDocumentService(documentsManager, createCommandExecutor); + service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_NAMESPACE); documentsManager.listen(connection); service.connect(connection) @@ -95,7 +96,7 @@ describe('KsonTextDocumentService', () => { assert.strictEqual(result.items.length, 1, "Should have exactly 1 diagnostic for trailing token"); assert.strictEqual(result.items[0].severity, DiagnosticSeverity.Error); - assert.strictEqual(result.items[0].source, 'kson'); + assert.strictEqual(result.items[0].source, TEST_NAMESPACE); }); it('should return empty items when document is not found', async () => { diff --git a/tooling/lsp-clients/vscode/package.json b/tooling/lsp-clients/vscode/package.json index 8245ab78..3b9c6abb 100644 --- a/tooling/lsp-clients/vscode/package.json +++ b/tooling/lsp-clients/vscode/package.json @@ -2,6 +2,7 @@ "name": "kson", "displayName": "KSON", "description": "Provides language support for the Kson data format.", + "ksonConfigNamespace": "kson", "authors": [ { "name": "KSON", diff --git a/tooling/lsp-clients/vscode/src/config/languageConfig.ts b/tooling/lsp-clients/vscode/src/config/languageConfig.ts index b2b9c2b6..33792d9e 100644 --- a/tooling/lsp-clients/vscode/src/config/languageConfig.ts +++ b/tooling/lsp-clients/vscode/src/config/languageConfig.ts @@ -1,5 +1,3 @@ -import { DEFAULT_CONFIG_NAMESPACE } from 'kson-language-server'; - /** * Configuration for bundled schemas mapped by file extension. */ @@ -17,7 +15,7 @@ export interface LanguageConfiguration { bundledSchemas: BundledSchemaMapping[]; /** * Prefix for VSCode commands and configuration keys (e.g. "kson"). - * Read from {@link CONFIG_NAMESPACE_MANIFEST_FIELD}, defaulting to "kson". + * Required; read from {@link CONFIG_NAMESPACE_MANIFEST_FIELD}. */ configNamespace: string; } @@ -28,8 +26,9 @@ export interface LanguageConfiguration { * (e.g. a derived one using a different `languageId`) sets this so the derived * extension doesn't collide with the base kson extension on install. * - * A derived extension's manifest must keep three things in sync: (1) set - * `ksonConfigNamespace` to the new namespace; (2) rewrite static contribution + * Every manifest must set this field; a derived extension keeps two things + * in sync: (1) set `ksonConfigNamespace` to its namespace (required — the base + * `kson` extension also sets this explicitly); (2) rewrite static contribution * keys — `contributes.commands[].command` ids and `contributes.configuration.properties` * keys — to live under that prefix instead of `kson.*`. Runtime call sites all * route through {@link getConfigNamespace}, so no source changes are required. @@ -71,6 +70,15 @@ export function getConfigNamespace(): string { export function initializeLanguageConfig(packageJson: any): void { const languages = packageJson?.contributes?.languages || []; + const configNamespace = packageJson?.[CONFIG_NAMESPACE_MANIFEST_FIELD]; + if (!configNamespace) { + throw new Error( + `Missing required package.json field "${CONFIG_NAMESPACE_MANIFEST_FIELD}". ` + + `A fork must declare this field at the top level of its manifest to name the ` + + `namespace under which its commands and configuration keys live.` + ); + } + // Extract bundled schema mappings using file extension from lang.extensions[0] const bundledSchemas: BundledSchemaMapping[] = languages .filter((lang: any) => lang.extensions?.[0] && lang.bundledSchema) @@ -86,9 +94,7 @@ export function initializeLanguageConfig(packageJson: any): void { .filter(Boolean) .map((ext: string) => ext.replace(/^\./, '')), bundledSchemas, - configNamespace: - packageJson?.[CONFIG_NAMESPACE_MANIFEST_FIELD] - || DEFAULT_CONFIG_NAMESPACE + configNamespace }; } diff --git a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index 3236a200..b1612003 100644 --- a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts @@ -7,7 +7,7 @@ describe('Language Configuration Tests', () => { afterEach(() => resetLanguageConfiguration()); function initWithLanguages(languages: any[]) { - initializeLanguageConfig({ contributes: { languages } }); + initializeLanguageConfig({ ksonConfigNamespace: 'kson', contributes: { languages } }); } describe('getLanguageConfiguration', () => { @@ -119,9 +119,15 @@ describe('Language Configuration Tests', () => { }); describe('configNamespace', () => { - it('Should default to "kson" when no languages are declared and no field is set', () => { - initWithLanguages([]); - assert.strictEqual(getConfigNamespace(), 'kson'); + it('Should throw when the ksonConfigNamespace manifest field is missing', () => { + let caught: Error | null = null; + try { + initializeLanguageConfig({ contributes: { languages: [{ id: 'kson', extensions: ['.kson'] }] } }); + } catch (e) { + caught = e as Error; + } + assert.ok(caught, 'expected initializeLanguageConfig to throw'); + assert.ok(caught!.message.includes('ksonConfigNamespace'), `error message should name the field, got: ${caught!.message}`); }); it('Should use the ksonConfigNamespace manifest field when present', () => { From c8fc451dc93e09d5fd8f5787dcfcb7ad640afcc2 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Thu, 7 May 2026 19:53:29 +0200 Subject: [PATCH 4/4] Derive distribution id from package.json name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `name` is already required to be unique per publisher and every contributed command id / configuration key in the manifest already lives under `kson.*`, so it IS the wire-level identifier. The client reads it directly and threads it as `distributionId` (renamed from the more generic `configNamespace`) into the LSP initialization options, the LanguageClient id, and `${name}.selectSchema` registrations. The redundant `ksonConfigNamespace` manifest field is gone since it duplicated `name` and could drift; the explicit "throw if missing" check is gone since VSCode itself rejects manifests without `name`. `KsonSettings` is also unwrapped — the server now strips the outer namespace key before applying defaults, so the type is `{ formatOptions, codeLensEnabled }` rather than `{ kson: {...} }`. --- .../src/core/commands/CommandType.ts | 31 +++++--------- .../src/core/features/DiagnosticService.ts | 4 +- .../core/services/KsonTextDocumentService.ts | 8 ++-- .../src/startKsonServer.ts | 41 +++++++++++-------- .../core/commands/CommandExecutor.test.ts | 6 +-- .../test/core/commands/CommandType.test.ts | 17 ++++---- .../core/features/DiagnosticService.test.ts | 14 +++---- .../services/KsonTextDocumentService.test.ts | 6 +-- tooling/lsp-clients/vscode/package.json | 1 - .../src/client/browser/ksonClientMain.ts | 12 +++--- .../src/client/common/StatusBarManager.ts | 4 +- .../vscode/src/client/node/ksonClientMain.ts | 16 ++++---- .../vscode/src/config/bundledSchemaLoader.ts | 6 +-- .../vscode/src/config/languageConfig.ts | 41 +------------------ .../vscode/test/suite/language-config.test.ts | 32 +-------------- 15 files changed, 82 insertions(+), 157 deletions(-) diff --git a/tooling/language-server-protocol/src/core/commands/CommandType.ts b/tooling/language-server-protocol/src/core/commands/CommandType.ts index e1b0ddd4..efaa50a7 100644 --- a/tooling/language-server-protocol/src/core/commands/CommandType.ts +++ b/tooling/language-server-protocol/src/core/commands/CommandType.ts @@ -1,13 +1,9 @@ /** * Enum defining all commands the Kson Language Server can execute. * - * Values are **unqualified** ids used as internal discriminators (executor - * switch, {@link ./CommandParameters} keys). The VSCode/LSP wire form prepends - * the active configuration namespace via {@link toWireCommandId} and is - * stripped again in {@link fromWireCommandId}, so a client built under a - * non-default namespace (e.g. `config.plainFormat`) can coexist with the - * base `kson` extension in the same VSCode host without command-registration - * collisions. + * Values are unqualified ids used internally as discriminators. On the wire, + * they are prefixed with the active distribution id via {@link toWireCommandId} + * and stripped again in {@link fromWireCommandId}. */ export enum CommandType { /** Format the document as plain Kson */ @@ -29,13 +25,6 @@ export enum CommandType { REMOVE_SCHEMA = 'removeSchema', } -/** - * Default configuration namespace used by the base kson extension and by the - * server when the client hasn't specified otherwise via initializationOptions. - * Commands and client settings both live under this prefix on the wire. - */ -export const DEFAULT_CONFIG_NAMESPACE = 'kson'; - /** * Get all unqualified command ids. */ @@ -45,19 +34,19 @@ export function getAllCommandIds(): CommandType[] { /** * Build the VSCode/LSP wire id for a command by prepending the active - * configuration namespace. + * distribution id. */ -export function toWireCommandId(commandId: CommandType | string, namespace: string): string { - return `${namespace}.${commandId}`; +export function toWireCommandId(commandId: CommandType | string, distributionId: string): string { + return `${distributionId}.${commandId}`; } /** * Parse a wire command id back to its {@link CommandType}, or return - * `undefined` if the id is not namespaced under `namespace` or doesn't resolve - * to a known command. + * `undefined` if the id is not prefixed by `distributionId` or doesn't + * resolve to a known command. */ -export function fromWireCommandId(wireId: string, namespace: string): CommandType | undefined { - const prefix = `${namespace}.`; +export function fromWireCommandId(wireId: string, distributionId: string): CommandType | undefined { + const prefix = `${distributionId}.`; if (!wireId.startsWith(prefix)) { return undefined; } diff --git a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts index f2f0a37f..e722421c 100644 --- a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts +++ b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts @@ -14,7 +14,7 @@ import {KsonTooling, DiagnosticMessage, DiagnosticSeverity as KtSeverity} from ' */ export class DiagnosticService { - constructor(private readonly configNamespace: string) {} + constructor(private readonly distributionId: string) {} createDocumentDiagnosticReport(document: KsonDocument | null | undefined): DocumentDiagnosticReport { const diagnostics = document ? this.getDiagnostics(document) : []; @@ -46,7 +46,7 @@ export class DiagnosticService { severity: msg.severity === KtSeverity.ERROR ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning, - source: this.configNamespace, + source: this.distributionId, message: msg.message }; } diff --git a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts index c78c6c10..efca3579 100644 --- a/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts +++ b/tooling/language-server-protocol/src/core/services/KsonTextDocumentService.ts @@ -69,10 +69,10 @@ export class KsonTextDocumentService { private documentManager: KsonDocumentsManager, private createCommandExecutor: CommandExecutorFactory, private workspaceRoot: string | null = null, - private configNamespace: string + private distributionId: string ) { this.formattingService = new FormattingService(); - this.diagnosticService = new DiagnosticService(configNamespace); + this.diagnosticService = new DiagnosticService(distributionId); this.semanticTokensService = new SemanticTokensService(); this.codeLensService = new CodeLensService(); this.documentHighlightService = new DocumentHighlightService(); @@ -198,7 +198,7 @@ export class KsonTextDocumentService { return this.codeLensService.getCodeLenses(document).map(lens => ({ ...lens, command: lens.command - ? {...lens.command, command: toWireCommandId(lens.command.command, this.configNamespace)} + ? {...lens.command, command: toWireCommandId(lens.command.command, this.distributionId)} : lens.command, })); } catch (error) { @@ -209,7 +209,7 @@ export class KsonTextDocumentService { private async onExecuteCommand(params: ExecuteCommandParams): Promise { try { - const commandType = fromWireCommandId(params.command, this.configNamespace); + const commandType = fromWireCommandId(params.command, this.distributionId); return await this.commandExecutor.execute( commandType ? {...params, command: commandType} : params ); diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 122c5503..99e118ab 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -11,7 +11,7 @@ import {KsonDocumentsManager} from './core/document/KsonDocumentsManager.js'; import {isKsonSchemaDocument} from './core/document/KsonSchemaDocument.js'; import {KsonTextDocumentService} from './core/services/KsonTextDocumentService.js'; import {KSON_LEGEND} from './core/features/SemanticTokensService.js'; -import {DEFAULT_CONFIG_NAMESPACE, getAllCommandIds, toWireCommandId} from './core/commands/CommandType.js'; +import {getAllCommandIds, toWireCommandId} from './core/commands/CommandType.js'; import { ksonSettingsWithDefaults } from './core/KsonSettings.js'; import {SchemaProvider} from './core/schema/SchemaProvider.js'; import {BundledSchemaProvider, BundledSchemaConfig, BundledMetaSchemaConfig} from './core/schema/BundledSchemaProvider.js'; @@ -30,12 +30,11 @@ export interface KsonInitializationOptions { /** Whether bundled schemas are enabled */ enableBundledSchemas?: boolean; /** - * Prefix for the client's configuration keys (defaults to "kson"). The - * server uses this when pulling settings from the client, so a client - * built under a different namespace reads `.format.*` / - * `.codeLens.*` instead of the base `kson.*`. + * Identifier under which this server instance's host-visible artifacts are + * registered: command-id prefix on the wire, diagnostic source, and the + * configuration section pulled from the client. Defaults to "kson". */ - configNamespace?: string; + distributionId?: string; } type SchemaProviderFactory = ( @@ -55,10 +54,17 @@ export function startKsonServer( createSchemaProvider: SchemaProviderFactory, createCommandExecutor: CommandExecutorFactory ): void { + /** + * Default distribution id, used when the client hasn't supplied one via + * initializationOptions. + */ + const DEFAULT_DISTRIBUTION_ID = "kson"; + + // Variables to store state during initialization let workspaceRootUri: URI | undefined; let hasConfigurationCapability = false; - let configNamespace: string; + let distributionId: string; // Create logger that uses the connection const logger = { @@ -88,7 +94,7 @@ export function startKsonServer( const bundledSchemas = initOptions?.bundledSchemas ?? []; const bundledMetaSchemas = initOptions?.bundledMetaSchemas ?? []; const enableBundledSchemas = initOptions?.enableBundledSchemas ?? true; - configNamespace = initOptions?.configNamespace ?? DEFAULT_CONFIG_NAMESPACE; + distributionId = initOptions?.distributionId ?? DEFAULT_DISTRIBUTION_ID; // Create the appropriate schema provider for this environment (file system or no-op) const fileSystemSchemaProvider = await createSchemaProvider(workspaceRootUri, logger); @@ -126,7 +132,7 @@ export function startKsonServer( documentManager, createCommandExecutor, workspaceRoot, - configNamespace + distributionId ); // Setup document handling and connect services @@ -148,7 +154,7 @@ export function startKsonServer( // Diagnostics (pull model preferred) diagnosticProvider: { - identifier: configNamespace, + identifier: distributionId, interFileDependencies: false, workspaceDiagnostics: false } as DiagnosticRegistrationOptions, @@ -158,11 +164,10 @@ export function startKsonServer( resolveProvider: false }, - // Execute command — advertise ids under the active namespace so a - // client built under a non-default namespace doesn't collide with - // the base `kson.*` registrations in VSCode's global command registry. + // Execute command — advertise ids prefixed by the active + // distribution id executeCommandProvider: { - commands: getAllCommandIds().map(id => toWireCommandId(id, configNamespace)) + commands: getAllCommandIds().map(id => toWireCommandId(id, distributionId)) }, // Folding ranges @@ -201,7 +206,7 @@ export function startKsonServer( // Pull configuration from the client async function pullConfiguration(): Promise { - const settings = await connection.workspace.getConfiguration(configNamespace); + const settings = await connection.workspace.getConfiguration(distributionId); const configuration = ksonSettingsWithDefaults(settings); textDocumentService.updateConfiguration(configuration); } @@ -211,7 +216,7 @@ export function startKsonServer( if (hasConfigurationCapability) { // Register for configuration change notifications connection.client.register(DidChangeConfigurationNotification.type, { - section: configNamespace + section: distributionId }); // Pull initial configuration await pullConfiguration(); @@ -284,9 +289,9 @@ export function startKsonServer( }); // Handle configuration changes. Per LSP, the push model sends the full - // settings object, so our slice arrives at `change.settings.`. + // settings object, so our slice arrives at `change.settings.`. connection.onDidChangeConfiguration(async (change) => { - const scoped = change.settings?.[configNamespace]; + const scoped = change.settings?.[distributionId]; if (hasConfigurationCapability) { // Pull configuration from the client (pull model) diff --git a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts index f607dcac..a404f4a9 100644 --- a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts +++ b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts @@ -35,12 +35,12 @@ class WorkspaceConnectionStub extends ConnectionStub { } } -const TEST_NAMESPACE = 'test-ns'; +const TEST_DISTRIBUTION_ID = 'test-ns'; function createTestSetup() { const connection = new ConnectionStub(); const documentsManager = new KsonDocumentsManager(); - const service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_NAMESPACE); + const service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_DISTRIBUTION_ID); documentsManager.listen(connection); service.connect(connection); @@ -78,7 +78,7 @@ function buildWorkspaceEdit(uri: string, replaceRange: Range, newText: string): function buildCommandParams(command: CommandType, uri: string, style: FormattingStyle): ExecuteCommandParams { return { - command: toWireCommandId(command, TEST_NAMESPACE), + command: toWireCommandId(command, TEST_DISTRIBUTION_ID), arguments: [{ documentUri: uri, formattingStyle: style }] }; } diff --git a/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts b/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts index d8de978f..77e62dfb 100644 --- a/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts +++ b/tooling/language-server-protocol/src/test/core/commands/CommandType.test.ts @@ -2,24 +2,23 @@ import {describe, it} from 'mocha'; import assert from 'assert'; import { CommandType, - DEFAULT_CONFIG_NAMESPACE, fromWireCommandId, getAllCommandIds, toWireCommandId, } from '../../../core/commands/CommandType.js'; -describe('CommandType wire-namespace translation', () => { - it('toWireCommandId / fromWireCommandId round-trip every CommandType under any namespace', () => { - for (const ns of [DEFAULT_CONFIG_NAMESPACE, 'config', 'a.b']) { - for (const id of getAllCommandIds()) { - const wire = toWireCommandId(id, ns); - assert.strictEqual(wire, `${ns}.${id}`); - assert.strictEqual(fromWireCommandId(wire, ns), id); +describe('CommandType wire-prefix translation', () => { + it('toWireCommandId / fromWireCommandId round-trip every CommandType under any distribution id', () => { + for (const id of ['kson', 'config', 'a.b']) { + for (const cmd of getAllCommandIds()) { + const wire = toWireCommandId(cmd, id); + assert.strictEqual(wire, `${id}.${cmd}`); + assert.strictEqual(fromWireCommandId(wire, id), cmd); } } }); - it('fromWireCommandId returns undefined for unknown ids or wrong namespace', () => { + it('fromWireCommandId returns undefined for unknown ids or wrong distribution id', () => { assert.strictEqual(fromWireCommandId('config.bogus', 'config'), undefined); assert.strictEqual(fromWireCommandId('other.plainFormat', 'config'), undefined); assert.strictEqual(fromWireCommandId('plainFormat', 'config'), undefined); diff --git a/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts b/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts index 8b7c0d18..c893db0d 100644 --- a/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/DiagnosticService.test.ts @@ -10,8 +10,8 @@ import {createKsonDocument} from '../../TestHelpers.js'; describe('KSON Diagnostics', () => { - const TEST_NAMESPACE = 'test-ns'; - const diagnosticService = new DiagnosticService(TEST_NAMESPACE); + const TEST_DISTRIBUTION_ID = 'test-ns'; + const diagnosticService = new DiagnosticService(TEST_DISTRIBUTION_ID); function getDiagnostics(content: string, schemaContent?: string): Diagnostic[] { const ksonDocument = createKsonDocument(content, schemaContent); @@ -30,7 +30,7 @@ describe('KSON Diagnostics', () => { it('should report error for empty document', () => { const diagnostics = assertDiagnosticCount('', 1); assert.strictEqual(diagnostics[0].severity, DiagnosticSeverity.Error); - assert.strictEqual(diagnostics[0].source, TEST_NAMESPACE); + assert.strictEqual(diagnostics[0].source, TEST_DISTRIBUTION_ID); }); it('should report no diagnostics for valid document', () => { @@ -67,10 +67,10 @@ describe('KSON Diagnostics', () => { assert.strictEqual(diagnostics[1].severity, DiagnosticSeverity.Warning); }); - it('should set source to the configured namespace on all diagnostics', () => { + it('should set source to the configured distribution id on all diagnostics', () => { const diagnostics = getDiagnostics(''); for (const d of diagnostics) { - assert.strictEqual(d.source, TEST_NAMESPACE); + assert.strictEqual(d.source, TEST_DISTRIBUTION_ID); } }); @@ -136,7 +136,7 @@ describe('KSON Diagnostics', () => { // Should still return at least the document parse errors, not crash assert.ok(diagnostics.length > 0, 'Should return document parse errors even when schema is invalid'); for (const d of diagnostics) { - assert.strictEqual(d.source, TEST_NAMESPACE); + assert.strictEqual(d.source, TEST_DISTRIBUTION_ID); } }); @@ -154,7 +154,7 @@ describe('KSON Diagnostics', () => { assert.ok(diagnostics.length > 0); // All diagnostics should come from parse errors only for (const d of diagnostics) { - assert.strictEqual(d.source, TEST_NAMESPACE); + assert.strictEqual(d.source, TEST_DISTRIBUTION_ID); } }); diff --git a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts index b1d03bec..d33f7131 100644 --- a/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts +++ b/tooling/language-server-protocol/src/test/core/services/KsonTextDocumentService.test.ts @@ -15,7 +15,7 @@ import {ksonSettingsWithDefaults} from "../../../core/KsonSettings.js"; import {pos} from "../../TestHelpers"; describe('KsonTextDocumentService', () => { - const TEST_NAMESPACE = 'test-ns'; + const TEST_DISTRIBUTION_ID = 'test-ns'; let connection: ConnectionStub; let service: KsonTextDocumentService; let documentsManager: KsonDocumentsManager; @@ -24,7 +24,7 @@ describe('KsonTextDocumentService', () => { beforeEach(() => { connection = new ConnectionStub(); documentsManager = new KsonDocumentsManager(); - service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_NAMESPACE); + service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_DISTRIBUTION_ID); documentsManager.listen(connection); service.connect(connection) @@ -96,7 +96,7 @@ describe('KsonTextDocumentService', () => { assert.strictEqual(result.items.length, 1, "Should have exactly 1 diagnostic for trailing token"); assert.strictEqual(result.items[0].severity, DiagnosticSeverity.Error); - assert.strictEqual(result.items[0].source, TEST_NAMESPACE); + assert.strictEqual(result.items[0].source, TEST_DISTRIBUTION_ID); }); it('should return empty items when document is not found', async () => { diff --git a/tooling/lsp-clients/vscode/package.json b/tooling/lsp-clients/vscode/package.json index 3b9c6abb..8245ab78 100644 --- a/tooling/lsp-clients/vscode/package.json +++ b/tooling/lsp-clients/vscode/package.json @@ -2,7 +2,6 @@ "name": "kson", "displayName": "KSON", "description": "Provides language support for the Kson data format.", - "ksonConfigNamespace": "kson", "authors": [ { "name": "KSON", diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index 54a03948..c4b0dba9 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/browser'; import { createClientOptions } from '../../config/clientOptions'; -import { initializeLanguageConfig, getConfigNamespace } from '../../config/languageConfig'; +import { initializeLanguageConfig } from '../../config/languageConfig'; import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; @@ -12,7 +12,7 @@ import { registerBundledSchemaContentProvider } from '../common/BundledSchemaCon export async function activate(context: vscode.ExtensionContext) { // Initialize language configuration from package.json initializeLanguageConfig(context.extension.packageJSON); - const configNamespace = getConfigNamespace(); + const name = context.extension.packageJSON.name; // Create log output channel const logOutputChannel = vscode.window.createOutputChannel('Kson Language Server', { log: true }); @@ -36,8 +36,8 @@ export async function activate(context: vscode.ExtensionContext) { const clientOptions = createClientOptions(logOutputChannel, { bundledSchemas, bundledMetaSchemas, - enableBundledSchemas: areBundledSchemasEnabled(), - configNamespace + enableBundledSchemas: areBundledSchemasEnabled(name), + distributionId: name }); // In test environments, we need to support the vscode-test-web scheme @@ -55,8 +55,8 @@ export async function activate(context: vscode.ExtensionContext) { } const languageClient = new LanguageClient( - `${configNamespace}-browser`, - 'KSON Language Server (Browser)', + `${name}-browser`, + `${name} Language Server (Browser)`, clientOptions, worker ); diff --git a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index 719af40c..84ec5884 100644 --- a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts +++ b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import {BaseLanguageClient} from 'vscode-languageclient'; import * as path from 'path'; -import { isKsonLanguage, getConfigNamespace } from '../../config/languageConfig'; +import { isKsonLanguage } from '../../config/languageConfig'; /** * Response from the LSP server for schema information @@ -26,7 +26,7 @@ export class StatusBarManager { vscode.StatusBarAlignment.Right, 100 ); - this.statusBarItem.command = `${getConfigNamespace()}.selectSchema`; + this.statusBarItem.command = `${client.name}.selectSchema`; } /** diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index 2e017a23..01753146 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -10,7 +10,7 @@ import { createClientOptions } from '../../config/clientOptions'; import {StatusBarManager} from '../common/StatusBarManager'; -import { isKsonLanguage, initializeLanguageConfig, getConfigNamespace } from '../../config/languageConfig'; +import { isKsonLanguage, initializeLanguageConfig } from '../../config/languageConfig'; import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; import { CommandType, toWireCommandId } from 'kson-language-server'; @@ -22,7 +22,7 @@ import { CommandType, toWireCommandId } from 'kson-language-server'; export async function activate(context: vscode.ExtensionContext) { // Initialize language configuration from package.json initializeLanguageConfig(context.extension.packageJSON); - const configNamespace = getConfigNamespace(); + const name = context.extension.packageJSON.name; // Create log output channel const logOutputChannel = vscode.window.createOutputChannel('Kson Language Server', {log: true}); @@ -55,10 +55,10 @@ export async function activate(context: vscode.ExtensionContext) { const clientOptions: LanguageClientOptions = createClientOptions(logOutputChannel, { bundledSchemas, bundledMetaSchemas, - enableBundledSchemas: areBundledSchemasEnabled(), - configNamespace + enableBundledSchemas: areBundledSchemasEnabled(name), + distributionId: name }); - const languageClient = new LanguageClient(configNamespace, serverOptions, clientOptions, false) + const languageClient = new LanguageClient(name, serverOptions, clientOptions, false) await languageClient.start(); @@ -88,7 +88,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register the schema selection command context.subscriptions.push( - vscode.commands.registerCommand(`${configNamespace}.selectSchema`, async () => { + vscode.commands.registerCommand(`${name}.selectSchema`, async () => { const editor = vscode.window.activeTextEditor; if (!editor || !isKsonLanguage(editor.document.languageId)) { vscode.window.showWarningMessage('Please open a KSON file first.'); @@ -136,7 +136,7 @@ export async function activate(context: vscode.ExtensionContext) { // Execute the remove schema command via LSP try { await languageClient.sendRequest('workspace/executeCommand', { - command: toWireCommandId(CommandType.REMOVE_SCHEMA, configNamespace), + command: toWireCommandId(CommandType.REMOVE_SCHEMA, name), arguments: [{ documentUri: editor.document.uri.toString() }] @@ -154,7 +154,7 @@ export async function activate(context: vscode.ExtensionContext) { try { // Execute the associate schema command via LSP await languageClient.sendRequest('workspace/executeCommand', { - command: toWireCommandId(CommandType.ASSOCIATE_SCHEMA, configNamespace), + command: toWireCommandId(CommandType.ASSOCIATE_SCHEMA, name), arguments: [{ documentUri: editor.document.uri.toString(), schemaPath: schemaPath diff --git a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts index 9f6ad589..2929b48b 100644 --- a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts +++ b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { getLanguageConfiguration, getConfigNamespace, BundledSchemaMapping } from './languageConfig'; +import { getLanguageConfiguration, BundledSchemaMapping } from './languageConfig'; import type { BundledSchemaConfig, BundledMetaSchemaConfig } from 'kson-language-server'; export type { BundledSchemaConfig, BundledMetaSchemaConfig }; @@ -115,7 +115,7 @@ async function loadSchemaFile( /** * Check if bundled schemas are enabled via VS Code settings. */ -export function areBundledSchemasEnabled(): boolean { - const config = vscode.workspace.getConfiguration(getConfigNamespace()); +export function areBundledSchemasEnabled(name: string): boolean { + const config = vscode.workspace.getConfiguration(name); return config.get('enableBundledSchemas', true); } diff --git a/tooling/lsp-clients/vscode/src/config/languageConfig.ts b/tooling/lsp-clients/vscode/src/config/languageConfig.ts index 33792d9e..805a7543 100644 --- a/tooling/lsp-clients/vscode/src/config/languageConfig.ts +++ b/tooling/lsp-clients/vscode/src/config/languageConfig.ts @@ -13,28 +13,8 @@ export interface LanguageConfiguration { fileExtensions: string[]; /** Bundled schema mappings extracted from package.json */ bundledSchemas: BundledSchemaMapping[]; - /** - * Prefix for VSCode commands and configuration keys (e.g. "kson"). - * Required; read from {@link CONFIG_NAMESPACE_MANIFEST_FIELD}. - */ - configNamespace: string; } -/** - * Root-level package.json field naming the namespace for VSCode commands and - * configuration keys. A build toolchain that produces a non-default extension - * (e.g. a derived one using a different `languageId`) sets this so the derived - * extension doesn't collide with the base kson extension on install. - * - * Every manifest must set this field; a derived extension keeps two things - * in sync: (1) set `ksonConfigNamespace` to its namespace (required — the base - * `kson` extension also sets this explicitly); (2) rewrite static contribution - * keys — `contributes.commands[].command` ids and `contributes.configuration.properties` - * keys — to live under that prefix instead of `kson.*`. Runtime call sites all - * route through {@link getConfigNamespace}, so no source changes are required. - */ -const CONFIG_NAMESPACE_MANIFEST_FIELD = 'ksonConfigNamespace'; - let cachedConfig: LanguageConfiguration | null = null; /** @@ -54,15 +34,6 @@ export function isKsonLanguage(languageId: string): boolean { return getLanguageConfiguration().languageIds.includes(languageId); } -/** - * Namespace prefix for this extension's commands and configuration keys at - * runtime. Contribution keys in package.json (commands, configuration - * properties) are static and must be rewritten separately when forking. - */ -export function getConfigNamespace(): string { - return getLanguageConfiguration().configNamespace; -} - /** * Initialize language configuration from extension's package.json. * Call this early in the activate function. @@ -70,15 +41,6 @@ export function getConfigNamespace(): string { export function initializeLanguageConfig(packageJson: any): void { const languages = packageJson?.contributes?.languages || []; - const configNamespace = packageJson?.[CONFIG_NAMESPACE_MANIFEST_FIELD]; - if (!configNamespace) { - throw new Error( - `Missing required package.json field "${CONFIG_NAMESPACE_MANIFEST_FIELD}". ` + - `A fork must declare this field at the top level of its manifest to name the ` + - `namespace under which its commands and configuration keys live.` - ); - } - // Extract bundled schema mappings using file extension from lang.extensions[0] const bundledSchemas: BundledSchemaMapping[] = languages .filter((lang: any) => lang.extensions?.[0] && lang.bundledSchema) @@ -93,8 +55,7 @@ export function initializeLanguageConfig(packageJson: any): void { .flatMap((lang: any) => lang.extensions || []) .filter(Boolean) .map((ext: string) => ext.replace(/^\./, '')), - bundledSchemas, - configNamespace + bundledSchemas }; } diff --git a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index b1612003..54e6cc5a 100644 --- a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts @@ -1,5 +1,5 @@ import { assert } from './assert'; -import { initializeLanguageConfig, getLanguageConfiguration, getConfigNamespace, isKsonLanguage, resetLanguageConfiguration } from '../../src/config/languageConfig'; +import { initializeLanguageConfig, getLanguageConfiguration, isKsonLanguage, resetLanguageConfiguration } from '../../src/config/languageConfig'; describe('Language Configuration Tests', () => { @@ -7,7 +7,7 @@ describe('Language Configuration Tests', () => { afterEach(() => resetLanguageConfiguration()); function initWithLanguages(languages: any[]) { - initializeLanguageConfig({ ksonConfigNamespace: 'kson', contributes: { languages } }); + initializeLanguageConfig({ contributes: { languages } }); } describe('getLanguageConfiguration', () => { @@ -118,32 +118,4 @@ describe('Language Configuration Tests', () => { }); }); - describe('configNamespace', () => { - it('Should throw when the ksonConfigNamespace manifest field is missing', () => { - let caught: Error | null = null; - try { - initializeLanguageConfig({ contributes: { languages: [{ id: 'kson', extensions: ['.kson'] }] } }); - } catch (e) { - caught = e as Error; - } - assert.ok(caught, 'expected initializeLanguageConfig to throw'); - assert.ok(caught!.message.includes('ksonConfigNamespace'), `error message should name the field, got: ${caught!.message}`); - }); - - it('Should use the ksonConfigNamespace manifest field when present', () => { - initializeLanguageConfig({ - ksonConfigNamespace: 'config', - contributes: { languages: [{ id: 'config', extensions: ['.config'] }] } - }); - assert.strictEqual(getConfigNamespace(), 'config'); - }); - - it('Should prefer the manifest field over languages[0].id', () => { - initializeLanguageConfig({ - ksonConfigNamespace: 'config', - contributes: { languages: [{ id: 'different', extensions: ['.different'] }] } - }); - assert.strictEqual(getConfigNamespace(), 'config'); - }); - }) });