Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/tall-taxis-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"env-spec-language": patch
---

Fix duplicate decorator diagnostics so repeated function-style decorators stay valid and split header root decorators still share one header scope.
45 changes: 41 additions & 4 deletions packages/vscode-plugin/src/completion-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type LineDocument = {
};

const HEADER_SEPARATOR_PATTERN = /^\s*#\s*---+\s*$/;
const ENV_ASSIGNMENT_PATTERN = /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=/;
const DECORATOR_PATTERN = /@([A-Za-z][\w-]*)/g;
const INCOMPATIBLE_DECORATORS = new Map<string, Set<string>>([
['required', new Set(['optional'])],
Expand Down Expand Up @@ -60,11 +61,47 @@ function splitArgs(input: string) {
return parts;
}

export function isInHeader(document: LineDocument, lineNumber: number) {
for (let line = lineNumber; line >= 0; line -= 1) {
if (HEADER_SEPARATOR_PATTERN.test(document.lineAt(line).text)) return false;
function getFirstConfigItemLine(document: LineDocument) {
for (let line = 0; line < document.lineCount; line += 1) {
if (ENV_ASSIGNMENT_PATTERN.test(document.lineAt(line).text)) return line;
}
return true;

return -1;
}

function getAttachedCommentBlockRange(document: LineDocument, firstConfigItemLine: number) {
if (firstConfigItemLine <= 0) return undefined;

const lastCommentLine = firstConfigItemLine - 1;
const lastCommentText = document.lineAt(lastCommentLine).text.trim();
if (!lastCommentText.startsWith('#') || HEADER_SEPARATOR_PATTERN.test(lastCommentText)) {
return undefined;
}

let start = lastCommentLine;
while (start > 0) {
const previousText = document.lineAt(start - 1).text.trim();
if (!previousText.startsWith('#') || HEADER_SEPARATOR_PATTERN.test(previousText)) break;
start -= 1;
}

return { start, end: lastCommentLine };
}

export function getCommentScope(document: LineDocument, lineNumber: number) {
const firstConfigItemLine = getFirstConfigItemLine(document);
if (firstConfigItemLine === -1 || lineNumber >= firstConfigItemLine) return 'item';

const attachedCommentBlock = getAttachedCommentBlockRange(document, firstConfigItemLine);
if (attachedCommentBlock && lineNumber >= attachedCommentBlock.start && lineNumber <= attachedCommentBlock.end) {
return 'item';
}

return 'header';
}

export function isInHeader(document: LineDocument, lineNumber: number) {
return getCommentScope(document, lineNumber) === 'header';
}

export function getExistingDecoratorNames(
Expand Down
6 changes: 5 additions & 1 deletion packages/vscode-plugin/src/diagnostics-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type DecoratorOccurrence = {
line: number;
start: number;
end: number;
isFunctionCall: boolean;
};

export type CoreDiagnostic = {
Expand Down Expand Up @@ -187,11 +188,13 @@ export function getDecoratorOccurrences(lineText: string, lineNumber: number) {
for (const match of lineText.matchAll(DECORATOR_PATTERN)) {
const name = match[1];
const start = match.index ?? 0;
const suffix = match[0].slice(name.length + 1);
occurrences.push({
name,
line: lineNumber,
start,
end: start + match[0].length,
isFunctionCall: suffix.startsWith('('),
});
}

Expand All @@ -208,7 +211,8 @@ export function createDecoratorDiagnostics(occurrences: Array<DecoratorOccurrenc
seenCounts.set(occurrence.name, count + 1);

const decorator = DECORATORS_BY_NAME[occurrence.name];
if (!decorator?.isFunction && count >= 1) {
const isRepeatableFunction = decorator?.isFunction ?? occurrence.isFunctionCall;
if (!isRepeatableFunction && count >= 1) {
diagnostics.push({
line: occurrence.line,
start: occurrence.start,
Expand Down
30 changes: 28 additions & 2 deletions packages/vscode-plugin/src/diagnostics-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from 'vscode';

import { LANG_ID } from './constants';
import { getCommentScope } from './completion-core';
import {
createDecoratorDiagnostics,
getDecoratorOccurrences,
Expand All @@ -33,14 +34,30 @@ function toRange(diagnostic: CoreDiagnostic) {
);
}

function validateDocument(document: TextDocument) {
type DiagnosticsDocument = Pick<TextDocument, 'languageId' | 'lineCount' | 'lineAt'>;

export function validateDocument(document: DiagnosticsDocument) {
if (document.languageId !== LANG_ID) return [];

const diagnostics: Array<Diagnostic> = [];
const lineDocument = createLineDocument(
Array.from({ length: document.lineCount }, (_, index) => document.lineAt(index).text),
);
let headerDecoratorBlock = [] as ReturnType<typeof getDecoratorOccurrences>;
let decoratorBlock = [] as ReturnType<typeof getDecoratorOccurrences>;
let hasSeenConfigItem = false;

const flushHeaderDecoratorBlock = () => {
if (!headerDecoratorBlock.length) return;
diagnostics.push(
...createDecoratorDiagnostics(headerDecoratorBlock).map((diagnostic) => new Diagnostic(
toRange(diagnostic),
diagnostic.message,
DiagnosticSeverity.Error,
)),
);
headerDecoratorBlock = [];
};

const flushDecoratorBlock = () => {
if (!decoratorBlock.length) return;
Expand All @@ -59,13 +76,21 @@ function validateDocument(document: TextDocument) {
const trimmed = lineText.trim();

if (trimmed.startsWith('#')) {
decoratorBlock.push(...getDecoratorOccurrences(lineText, lineNumber));
if (!hasSeenConfigItem && getCommentScope(lineDocument, lineNumber) === 'header') {
headerDecoratorBlock.push(...getDecoratorOccurrences(lineText, lineNumber));
} else {
decoratorBlock.push(...getDecoratorOccurrences(lineText, lineNumber));
}
} else if (trimmed === '' && !hasSeenConfigItem) {
continue;
} else {
flushHeaderDecoratorBlock();
flushDecoratorBlock();
}

const match = lineText.match(ENV_ASSIGNMENT_PATTERN);
if (!match) continue;
hasSeenConfigItem = true;

const rawValue = stripInlineComment(match[2]);
if (!rawValue) continue;
Expand All @@ -88,6 +113,7 @@ function validateDocument(document: TextDocument) {
));
}

flushHeaderDecoratorBlock();
flushDecoratorBlock();
return diagnostics;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/vscode-plugin/test/completion-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import {
filterAvailableDecorators,
getCommentScope,
getEnumValuesFromPrecedingComments,
getExistingDecoratorNames,
getTypeOptionDataType,
Expand All @@ -13,14 +14,18 @@ import { DATA_TYPES, ITEM_DECORATORS } from '../src/intellisense-catalog';
describe('completion-core', () => {
it('detects root header vs item sections', () => {
const document = createLineDocument([
'# header',
'',
'# @defaultRequired=false',
'# ---',
'',
'# @required',
'APP_ENV=',
]);

expect(isInHeader(document, 0)).toBe(true);
expect(isInHeader(document, 2)).toBe(false);
expect(isInHeader(document, 2)).toBe(true);
expect(isInHeader(document, 4)).toBe(false);
expect(getCommentScope(document, 4)).toBe('item');
});

it('collects decorators already used in the current block', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode-plugin/test/diagnostics-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('diagnostics-core', () => {
const duplicates = createDecoratorDiagnostics([
...getDecoratorOccurrences('# @required @required', 0),
...getDecoratorOccurrences('# @docs(https://a.com) @docs(https://b.com)', 1),
...getDecoratorOccurrences('# @initOp(allowAppAuth=true) @initOp(token=$OP_TOKEN)', 2),
]);

expect(duplicates.map((diagnostic) => diagnostic.message)).toContain(
Expand All @@ -21,6 +22,9 @@ describe('diagnostics-core', () => {
expect(
duplicates.some((diagnostic) => diagnostic.message.includes('@docs')),
).toBe(false);
expect(
duplicates.some((diagnostic) => diagnostic.message.includes('@initOp')),
).toBe(false);
});

it('flags incompatible decorator pairs inline', () => {
Expand Down
86 changes: 86 additions & 0 deletions packages/vscode-plugin/test/diagnostics-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
beforeAll, describe, expect, it, vi,
} from 'vitest';

vi.mock('vscode', () => ({
Diagnostic: class {
range: unknown;
message: string;
severity: unknown;

constructor(range: unknown, message: string, severity: unknown) {
this.range = range;
this.message = message;
this.severity = severity;
}
},
DiagnosticSeverity: { Error: 0 },
Disposable: class {},
Position: class {
constructor(public line: number, public character: number) {}
},
Range: class {
constructor(public start: unknown, public end: unknown) {}
},
languages: { createDiagnosticCollection: vi.fn() },
workspace: {
onDidOpenTextDocument: vi.fn(),
onDidChangeTextDocument: vi.fn(),
onDidCloseTextDocument: vi.fn(),
textDocuments: [],
},
}));

let validateDocument: typeof import('../src/diagnostics-provider').validateDocument;

beforeAll(async () => {
({ validateDocument } = await import('../src/diagnostics-provider'));
});

function createTestDocument(lines: Array<string>) {
return {
languageId: 'env-spec',
lineCount: lines.length,
lineAt(line: number) {
return { text: lines[line] };
},
};
}

describe('diagnostics-provider', () => {
it('keeps the header scope across blank lines before the first config item', () => {
const diagnostics = validateDocument(createTestDocument([
'# @defaultRequired=true',
'',
'# @defaultRequired=false',
'',
'# @required',
'',
'ITEM=',
]));

expect(diagnostics.map((diagnostic) => diagnostic.message)).toContain(
'@defaultRequired can only be used once in the same decorator block.',
);
expect(diagnostics.map((diagnostic) => diagnostic.message)).not.toContain(
'@required can only be used once in the same decorator block.',
);
});

it('treats the last comment block before the first item as item-attached', () => {
const diagnostics = validateDocument(createTestDocument([
'# @defaultRequired=true',
'',
'# @required',
'# @required',
'ITEM=',
]));

expect(diagnostics.map((diagnostic) => diagnostic.message)).toContain(
'@required can only be used once in the same decorator block.',
);
expect(diagnostics.map((diagnostic) => diagnostic.message)).not.toContain(
'@defaultRequired can only be used once in the same decorator block.',
);
});
});
Loading