Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/commonMain/kotlin/org/kson/ast/EmbedBlockResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<InternalEmbedRule>
): EmbedBlockResolution {
if (rules.isEmpty()) return EmbedBlockResolution.EMPTY
val rootValue = root.toKsonValue()
val stringResult = mutableMapOf<StringNode, InternalEmbedRule>()
for (rule in rules) {
val matchingValues = KsonValueWalker.navigateWithJsonPointerGlob(rootValue, rule.pathPattern)
Expand Down
6 changes: 3 additions & 3 deletions src/commonMain/kotlin/org/kson/tools/Formatter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions src/commonTest/kotlin/org/kson/tools/FormatterTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
34 changes: 19 additions & 15 deletions tooling/language-server-protocol/src/core/KsonSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KsonSettings> {
// 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 {
Expand All @@ -33,9 +39,9 @@ export function ksonSettingsWithDefaults(settings?: LSPAny): Required<KsonSettin

// Create FormattingStyle based on the provided settings
let formatStyle: FormattingStyle
if (settings?.kson?.format?.formattingStyle) {
if (settings?.format?.formattingStyle) {
// Map lowercase string to uppercase enum value exhaustively
const style = settings.kson.format.formattingStyle.toLowerCase();
const style = settings.format.formattingStyle.toLowerCase();
switch (style) {
case 'plain':
formatStyle = FormattingStyle.PLAIN;
Expand All @@ -53,12 +59,10 @@ export function ksonSettingsWithDefaults(settings?: LSPAny): Required<KsonSettin
}

// CodeLens enabled by default
const codeLensEnabled = settings?.kson?.codeLens?.enable !== false;
const codeLensEnabled = settings?.codeLens?.enable !== false;

return {
kson: {
formatOptions: new FormatOptions(indentType, formatStyle),
codeLensEnabled
}
formatOptions: new FormatOptions(indentType, formatStyle),
codeLensEnabled
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export abstract class CommandExecutorBase {
case CommandType.DELIMITED_FORMAT:
case CommandType.COMPACT_FORMAT:
case CommandType.CLASSIC_FORMAT: {
const indentType = this.getConfiguration().kson.formatOptions.indentType;
const indentType = this.getConfiguration().formatOptions.indentType;

return this.executeFormat(commandArgs.documentUri, document, new FormatOptions(
indentType,
Expand Down
82 changes: 49 additions & 33 deletions tooling/language-server-protocol/src/core/commands/CommandType.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,57 @@
/**
* Enum defining all available commands in the Kson Language Server
* Enum defining all commands the Kson Language Server can execute.
*
* 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
*/
PLAIN_FORMAT = 'kson.plainFormat',

/**
* Format the document as delimited Kson
*/
DELIMITED_FORMAT = 'kson.delimitedFormat',

/**
* Format the document as compact Kson
*/
COMPACT_FORMAT = 'kson.compactFormat',

/**
* Format the document as classic Kson
*/
CLASSIC_FORMAT = 'kson.classicFormat',

/**
* Associate a schema with the current document
*/
ASSOCIATE_SCHEMA = 'kson.associateSchema',

/**
* Remove schema association from the current document
*/
REMOVE_SCHEMA = 'kson.removeSchema'
/** Format the document as plain Kson */
PLAIN_FORMAT = 'plainFormat',

/** Format the document as delimited Kson */
DELIMITED_FORMAT = 'delimitedFormat',

/** Format the document as compact Kson */
COMPACT_FORMAT = 'compactFormat',

/** Format the document as classic Kson */
CLASSIC_FORMAT = 'classicFormat',

/** Associate a schema with the current document */
ASSOCIATE_SCHEMA = 'associateSchema',

/** Remove schema association from the current document */
REMOVE_SCHEMA = 'removeSchema',
}

/**
* Get all available command IDs
* Get all unqualified command ids.
*/
export function getAllCommandIds(): string[] {
export function getAllCommandIds(): CommandType[] {
return Object.values(CommandType);
}
}

/**
* Build the VSCode/LSP wire id for a command by prepending the active
* distribution id.
*/
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 prefixed by `distributionId` or doesn't
* resolve to a known command.
*/
export function fromWireCommandId(wireId: string, distributionId: string): CommandType | undefined {
const prefix = `${distributionId}.`;
if (!wireId.startsWith(prefix)) {
return undefined;
}
const unqualified = wireId.slice(prefix.length);
return (Object.values(CommandType) as string[]).includes(unqualified)
? (unqualified as CommandType)
: undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {KsonTooling, DiagnosticMessage, DiagnosticSeverity as KtSeverity} from '
*/
export class DiagnosticService {

constructor(private readonly distributionId: string) {}

createDocumentDiagnosticReport(document: KsonDocument | null | undefined): DocumentDiagnosticReport {
const diagnostics = document ? this.getDiagnostics(document) : [];
return {
Expand All @@ -32,20 +34,20 @@ export class DiagnosticService {
.validateDocument(toolingDoc, schemaToolingDoc ?? null)
.asJsReadonlyArrayView();

return messages.map(msg => 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.distributionId,
message: msg.message
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ 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 {
fromWireCommandId,
toWireCommandId,
} from '../commands/CommandType.js';
import {KsonSettings, ksonSettingsWithDefaults} from '../KsonSettings.js';


Expand All @@ -64,10 +68,11 @@ export class KsonTextDocumentService {
constructor(
private documentManager: KsonDocumentsManager,
private createCommandExecutor: CommandExecutorFactory,
private workspaceRoot: string | null = null
private workspaceRoot: string | null = null,
private distributionId: string
) {
this.formattingService = new FormattingService();
this.diagnosticService = new DiagnosticService();
this.diagnosticService = new DiagnosticService(distributionId);
this.semanticTokensService = new SemanticTokensService();
this.codeLensService = new CodeLensService();
this.documentHighlightService = new DocumentHighlightService();
Expand Down Expand Up @@ -158,7 +163,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 [];
Expand All @@ -183,14 +188,19 @@ export class KsonTextDocumentService {

private async onCodeLens(params: CodeLensParams): Promise<CodeLens[]> {
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.distributionId)}
: lens.command,
}));
} catch (error) {
this.connection.console.error(`Error providing code lenses: ${error}`);
return [];
Expand All @@ -199,7 +209,10 @@ export class KsonTextDocumentService {

private async onExecuteCommand(params: ExecuteCommandParams): Promise<any> {
try {
return await this.commandExecutor.execute(params);
const commandType = fromWireCommandId(params.command, this.distributionId);
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}`);
Expand Down
1 change: 1 addition & 0 deletions tooling/language-server-protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -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, toWireCommandId } from './core/commands/CommandType.js';
Loading