blob: e60c6a2cee27e5a3f50e6e44fd46b08a82870de9 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface HjsonContext {
/**
* The path of keys leading to the current position, e.g., ["query", "dataSource"]
* For arrays, includes the index as a string key, e.g., ["filters", "0", "dimension"]
* Empty array if at root level
*/
path: string[];
/**
* Whether the cursor is positioned where a key should be entered (true)
* or where a value should be entered (false)
*/
isEditingKey: boolean;
/**
* If editing a value (isEditingKey === false), this is the key for that value
* If editing a key (isEditingKey === true), this is undefined
*/
currentKey?: string;
/**
* Whether the cursor is positioned inside a comment (single-line or multi-line)
*/
isEditingComment: boolean;
/**
* The current JSON object being edited at the cursor position.
* This is the object that contains the property/value being typed.
* For completions, this provides context about what properties already exist.
*/
currentObject: any;
}
/**
* Analyzes an Hjson string (from start to cursor position) and returns
* context information about where the cursor is positioned within the JSON structure
*
* @param hjson - The Hjson text from the beginning of the document to the cursor position
* @returns Context information about the cursor position
*/
export function getHjsonContext(hjson: string): HjsonContext {
// Empty input
if (!hjson.trim()) {
return {
path: [],
isEditingKey: true,
currentKey: undefined,
isEditingComment: false,
currentObject: {},
};
}
// State machine state
const path: string[] = [];
const containerStack: { type: 'object' | 'array'; index: number }[] = [];
const objectStack: any[] = [{}];
let state:
| 'normal'
| 'quoted-string'
| 'single-line-comment'
| 'multi-line-comment'
| 'multiline-string' = 'normal';
let stringDelim = '';
let token = '';
let currentKey: string | undefined;
let afterColon = false;
// Process each character
for (let i = 0; i < hjson.length; i++) {
const ch = hjson[i];
const next = hjson[i + 1];
// State transitions
if (state === 'quoted-string') {
token += ch;
if (ch === stringDelim && hjson[i - 1] !== '\\') {
state = 'normal';
// If in array expecting value, push completed string
if (afterColon && currentKey && containerStack.length > 0) {
const container = containerStack[containerStack.length - 1];
if (container.type === 'array') {
objectStack[objectStack.length - 1].push(parseValue(token));
token = '';
afterColon = false;
}
}
}
continue;
}
if (state === 'single-line-comment') {
if (ch === '\n') state = 'normal';
continue;
}
if (state === 'multi-line-comment') {
if (ch === '*' && next === '/') {
state = 'normal';
i++; // Skip '/'
}
continue;
}
if (state === 'multiline-string') {
if (ch === "'" && next === "'" && hjson[i + 2] === "'") {
// End of multiline string
state = 'normal';
i += 2; // Skip the other two quotes
// Process the completed multiline string value
if (afterColon && currentKey) {
getCurrentObject()[currentKey] = parseMultilineString(token);
token = '';
currentKey = undefined;
afterColon = false;
}
} else {
token += ch;
}
continue;
}
// Normal state processing
// Check for comment start
if (ch === '#') {
state = 'single-line-comment';
continue;
}
if (ch === '/' && next === '/') {
state = 'single-line-comment';
i++; // Skip second '/'
continue;
}
if (ch === '/' && next === '*') {
state = 'multi-line-comment';
i++; // Skip '*'
continue;
}
// Check for multiline string start
if (ch === "'" && next === "'" && hjson[i + 2] === "'") {
state = 'multiline-string';
token = ''; // Don't include the triple quotes
i += 2; // Skip the other two quotes
continue;
}
// String start
if (ch === '"' || ch === "'") {
state = 'quoted-string';
stringDelim = ch;
token += ch;
continue;
}
// Structural characters
switch (ch) {
case '{': {
// Handle Hjson no-comma case
if (afterColon && currentKey && token.trim()) {
getCurrentObject()[currentKey] = parseValue(token.trim());
path.push(currentKey);
} else if (token.trim()) {
path.push(extractKey(token));
} else if (afterColon && currentKey) {
path.push(currentKey);
} else if (
containerStack.length > 0 &&
containerStack[containerStack.length - 1].type === 'array'
) {
path.push(String(containerStack[containerStack.length - 1].index));
}
containerStack.push({ type: 'object', index: 0 });
objectStack.push({});
token = '';
currentKey = undefined;
afterColon = false;
break;
}
case '[': {
if (token.trim()) {
path.push(extractKey(token));
} else if (afterColon && currentKey) {
path.push(currentKey);
}
containerStack.push({ type: 'array', index: 0 });
objectStack.push([]);
token = '';
currentKey = undefined;
afterColon = false;
break;
}
case '}':
case ']': {
// Complete pending value
if (token.trim()) {
if (
containerStack.length > 0 &&
containerStack[containerStack.length - 1].type === 'array'
) {
// We're in an array, add the item
const arr = objectStack[objectStack.length - 1] as any[];
arr.push(parseValue(token.trim()));
} else if (afterColon && currentKey) {
// We're completing an object property
getCurrentObject()[currentKey] = parseValue(token.trim());
}
}
// Pop container
const completed = objectStack.pop();
containerStack.pop();
if (path.length > 0) {
const key = path.pop()!;
if (objectStack.length > 0 && completed !== undefined) {
const parent = objectStack[objectStack.length - 1];
if (!Array.isArray(parent)) {
parent[key] = completed;
}
}
}
token = '';
currentKey = undefined;
afterColon = false;
break;
}
case ':': {
currentKey = extractKey(token);
afterColon = true;
token = '';
break;
}
case ',': {
// Complete value
if (token.trim()) {
if (
containerStack.length > 0 &&
containerStack[containerStack.length - 1].type === 'array'
) {
// We're in an array, add the item
const arr = objectStack[objectStack.length - 1] as any[];
arr.push(parseValue(token.trim()));
} else if (afterColon && currentKey) {
// We're completing an object property
getCurrentObject()[currentKey] = parseValue(token.trim());
}
}
// Update array index
if (containerStack.length > 0) {
const container = containerStack[containerStack.length - 1];
if (container.type === 'array') {
container.index++;
}
}
token = '';
currentKey = undefined;
afterColon = false;
break;
}
default: {
if (/\s/.test(ch)) {
// Newline can complete a value in Hjson
if (ch === '\n' && afterColon && currentKey && token.trim()) {
getCurrentObject()[currentKey] = parseValue(token.trim());
token = '';
currentKey = undefined;
afterColon = false;
}
} else {
token += ch;
}
}
}
}
// Determine context
let isEditingKey: boolean;
let finalKey: string | undefined;
if (containerStack.length === 0) {
// Root level - partial keys should not be considered as currentKey
isEditingKey = true;
finalKey = undefined;
} else {
const container = containerStack[containerStack.length - 1];
if (container.type === 'array') {
isEditingKey = false;
finalKey = String(container.index);
} else {
isEditingKey = !afterColon;
if (afterColon) {
finalKey = currentKey;
} else if (
token.trim() &&
((getCurrentObject() && Object.keys(getCurrentObject()).length > 0) ||
containerStack.length > 1)
) {
// Set currentKey for partial keys in non-empty objects (trailing comma case) or nested contexts
finalKey = extractKey(token);
}
}
}
return {
path,
isEditingKey,
currentKey: finalKey,
isEditingComment: state === 'single-line-comment' || state === 'multi-line-comment',
currentObject: getCurrentObject(),
};
function getCurrentObject(): any {
if (objectStack.length === 0) return {};
const current = objectStack[objectStack.length - 1];
return Array.isArray(current) ? objectStack[objectStack.length - 2] || {} : current;
}
}
function extractKey(token: string): string {
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
return token;
}
function parseValue(token: string): any {
const trimmed = token.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
return trimmed.includes('.') ? parseFloat(trimmed) : parseInt(trimmed, 10);
}
return trimmed;
}
function parseMultilineString(content: string): string {
// Hjson multiline strings preserve line breaks but trim common leading whitespace
const lines = content.split('\n');
// Find minimum leading whitespace (excluding empty lines)
let minIndent = Infinity;
for (const line of lines) {
if (line.trim()) {
const match = /^(\s*)/.exec(line);
const leadingSpaces = match?.[0].length || 0;
minIndent = Math.min(minIndent, leadingSpaces);
}
}
// Remove common leading whitespace
const processedLines = lines.map(line => line.slice(minIndent === Infinity ? 0 : minIndent));
// Trim first and last line if empty
if (processedLines.length > 0 && !processedLines[0].trim()) {
processedLines.shift();
}
if (processedLines.length > 0 && !processedLines[processedLines.length - 1].trim()) {
processedLines.pop();
}
return processedLines.join('\n');
}