Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { StringStream } from '@codemirror/language';
import { describe, expect, it } from 'vitest';
import { cosmosDbSqlStreamParser } from './streamParser.js';

interface Token {
text: string;
type: string | null;
}

/**
* Tokenize a single line, threading the parser state. Returns the tokens and
* the resulting state so multi-line contexts (strings, block comments) can be
* tested across lines.
*/
function tokenizeLine(line: string, state = cosmosDbSqlStreamParser.startState!(4)) {
const stream = new StringStream(line, 4, 4);
const tokens: Token[] = [];
let guard = 0;
while (!stream.eol() && guard++ < 1000) {
const start = stream.pos;
const type = cosmosDbSqlStreamParser.token(stream, state);
if (stream.pos === start) {
// Defensive: avoid an infinite loop if the parser failed to advance.
stream.next();
}
tokens.push({ text: line.slice(start, stream.pos), type });
}
return { tokens, state };
}

/** Convenience: tokenize a single-token line and return its type. */
function typeOf(line: string): string | null {
const { tokens } = tokenizeLine(line);
// Filter out whitespace tokens (null type from eatSpace).
const nonNull = tokens.filter((t) => t.type !== null);
return nonNull.length === 1 ? nonNull[0].type : nonNull.map((t) => t.type).join(',');
}

describe('cosmosDbSqlStreamParser', () => {
it('starts in the top context', () => {
expect(cosmosDbSqlStreamParser.startState!(4)).toEqual({ context: 'top' });
});

it('classifies keywords, operator keywords, builtins and identifiers', () => {
expect(typeOf('SELECT')).toBe('keyword');
expect(typeOf('AND')).toBe('operatorKeyword');
expect(typeOf('COUNT')).toBe('function(definition)');
expect(typeOf('myField')).toBe('variableName');
});

it('classifies numbers (int, hex, float, exponent)', () => {
expect(typeOf('123')).toBe('number');
expect(typeOf('0xFF')).toBe('number');
expect(typeOf('3.14')).toBe('number');
expect(typeOf('1e5')).toBe('number');
});

it('classifies operators, parens and punctuation', () => {
expect(typeOf('>=')).toBe('operator');
expect(typeOf('||')).toBe('operator');
expect(typeOf('+')).toBe('operator');
expect(typeOf('(')).toBe('paren');
expect(typeOf(',')).toBe('punctuation');
});

it('classifies line comments', () => {
expect(typeOf('-- a comment')).toBe('lineComment');
});

it('classifies a single-line block comment and returns to top', () => {
const { tokens, state } = tokenizeLine('/* hi */');
expect(tokens[0].type).toBe('blockComment');
expect(state.context).toBe('top');
});

it('continues a block comment across lines', () => {
const first = tokenizeLine('/* start');
expect(first.state.context).toBe('blockComment');
const second = tokenizeLine('end */', first.state);
// The comment terminator is consumed and the parser returns to the top context.
expect(second.tokens[0].type).toBe('blockComment');
expect(second.state.context).toBe('top');
});

it('classifies single-quoted strings, including escaped quotes', () => {
expect(typeOf("'abc'")).toBe('string');
expect(typeOf("'a''b'")).toBe('string');
});

it('continues an unterminated single-quoted string across lines', () => {
const first = tokenizeLine("'abc");
expect(first.state.context).toBe('singleString');
const second = tokenizeLine("def'", first.state);
expect(second.tokens[0].type).toBe('string');
expect(second.state.context).toBe('top');
});

it('classifies double-quoted identifiers as string.special', () => {
expect(typeOf('"ident"')).toBe('string.special');
});

it('continues an unterminated quoted identifier across lines', () => {
const first = tokenizeLine('"abc');
expect(first.state.context).toBe('quotedIdentifier');
const second = tokenizeLine('def"', first.state);
expect(second.tokens[0].type).toBe('string.special');
expect(second.state.context).toBe('top');
});

it('returns null for whitespace-only input', () => {
const { tokens } = tokenizeLine(' ');
expect(tokens.every((t) => t.type === null)).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SqlLanguageService } from '../../services/SqlLanguageService.js';
import { MonacoDiagnosticsProvider } from './diagnosticsProvider.js';
import { type MonacoNamespace } from './types.js';

function makeModel(text: string, languageId = 'cosmosdb-sql') {
const contentListeners: (() => void)[] = [];
const disposeListeners: (() => void)[] = [];
return {
getValue: () => text,
getLanguageId: () => languageId,
onDidChangeContent: vi.fn((cb: () => void) => {
contentListeners.push(cb);
return { dispose: vi.fn() };
}),
onWillDispose: vi.fn((cb: () => void) => {
disposeListeners.push(cb);
return { dispose: vi.fn() };
}),
fireChange: () => contentListeners.forEach((c) => c()),
fireDispose: () => disposeListeners.forEach((c) => c()),
};
}

function createMonacoMock(models: ReturnType<typeof makeModel>[]) {
const listeners: {
createModel?: (m: unknown) => void;
changeLang?: (e: unknown) => void;
} = {};
const monaco = {
listeners,
editor: {
getModels: () => models,
setModelMarkers: vi.fn(),
onDidCreateModel: vi.fn((cb: (m: unknown) => void) => {
listeners.createModel = cb;
return { dispose: vi.fn() };
}),
onDidChangeModelLanguage: vi.fn((cb: (e: unknown) => void) => {
listeners.changeLang = cb;
return { dispose: vi.fn() };
}),
},
MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 },
};
return monaco as unknown as MonacoNamespace & typeof monaco;
}

const INVALID_QUERY = 'SELECT * FORM c';
const VALID_QUERY = 'SELECT * FROM c';

describe('MonacoDiagnosticsProvider', () => {
let service: SqlLanguageService;

beforeEach(() => {
service = new SqlLanguageService();
});

afterEach(() => {
vi.useRealTimers();
});

it('registers model lifecycle listeners', () => {
const monaco = createMonacoMock([]);
new MonacoDiagnosticsProvider(monaco, service);
expect(monaco.editor.onDidCreateModel).toHaveBeenCalled();
expect(monaco.editor.onDidChangeModelLanguage).toHaveBeenCalled();
});

it('publishes markers for existing matching models on construction', () => {
const model = makeModel(INVALID_QUERY);
const monaco = createMonacoMock([model as unknown as ReturnType<typeof makeModel>]);
new MonacoDiagnosticsProvider(monaco, service);
expect(monaco.editor.setModelMarkers).toHaveBeenCalledTimes(1);
const [, owner, markers] = monaco.editor.setModelMarkers.mock.calls[0];
expect(owner).toBe('cosmosdb-sql');
expect((markers as unknown[]).length).toBeGreaterThan(0);
// severity is mapped to a Monaco MarkerSeverity value
expect((markers as { severity: number }[])[0].severity).toBe(monaco.MarkerSeverity.Error);
});

it('ignores models with a non-matching language', () => {
const model = makeModel(INVALID_QUERY, 'plaintext');
const monaco = createMonacoMock([model]);
new MonacoDiagnosticsProvider(monaco, service);
expect(monaco.editor.setModelMarkers).not.toHaveBeenCalled();
});

it('observes models created after construction', () => {
const monaco = createMonacoMock([]);
new MonacoDiagnosticsProvider(monaco, service);
const model = makeModel(VALID_QUERY);
monaco.listeners.createModel?.(model);
expect(model.onDidChangeContent).toHaveBeenCalled();
expect(monaco.editor.setModelMarkers).toHaveBeenCalled();
});

it('debounces marker updates on content change', () => {
vi.useFakeTimers();
const model = makeModel(VALID_QUERY);
const monaco = createMonacoMock([model]);
new MonacoDiagnosticsProvider(monaco, service, { diagnosticDelay: 100 });
monaco.editor.setModelMarkers.mockClear();

model.fireChange();
expect(monaco.editor.setModelMarkers).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(monaco.editor.setModelMarkers).toHaveBeenCalled();
});

it('clears markers when a model is disposed', () => {
const model = makeModel(VALID_QUERY);
const monaco = createMonacoMock([model]);
new MonacoDiagnosticsProvider(monaco, service);
monaco.editor.setModelMarkers.mockClear();

model.fireDispose();
const lastCall = monaco.editor.setModelMarkers.mock.calls.at(-1);
expect(lastCall?.[2]).toHaveLength(0);
});

it('dispose unobserves all models', () => {
const model = makeModel(VALID_QUERY);
const monaco = createMonacoMock([model]);
const provider = new MonacoDiagnosticsProvider(monaco, service);
expect(() => provider.dispose()).not.toThrow();
});
});
Loading
Loading