Skip to content

Commit 412d6c4

Browse files
authored
Added diagnostic support to detect duplicate properties and strings (#26)
1 parent 8605870 commit 412d6c4

22 files changed

+724
-57
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ An extension for [Visual Studio Code](https://code.visualstudio.com) that enable
1616
- Property and Value Autocompletion Hints
1717
- Documentation Hints on Hover
1818
- Document Outline Support
19+
- Diagnostics
1920
- Jump Link Support for the `$include` Function
2021

2122
## Supported File Formats

server/documentation/FARM_FILTER_URL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
The `/url` proeprty is used to filter the request based on its URL.
1+
The `/url` property is used to filter the request based on its URL.
22

33
---
44

server/src/core/definition-provider.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { INCLUDE_FUNCTION_NAME } from "@language-server/constants/function";
77
import { DocumentParserTreeManager } from "@language-server/core/document-parser-tree-manager";
88
import { ApacheDispatcherConfigToken, getCurrentSyntaxNode } from "@language-server/core/tree-sitter";
99
import { FileExistenceContext, getFileExistenceContext } from "@language-server/utils/file-system";
10+
import { removeOuterQuotes } from "@language-server/utils/string";
1011
import {
1112
DefinitionLink,
1213
DefinitionParams,
@@ -19,22 +20,6 @@ import Parser = require("web-tree-sitter");
1920
const START_POSITION: Position = Position.create(0, 0);
2021
const START_POSITION_RANGE: Range = Range.create(START_POSITION, START_POSITION);
2122

22-
function isSingleQuoteString(text: string): boolean {
23-
return text.startsWith("'") && text.endsWith("'");
24-
}
25-
26-
function isDoubleQuoteString(text: string): boolean {
27-
return text.startsWith("\"") && text.endsWith("\"");
28-
}
29-
30-
function removeOuterQuotes(text: string): string {
31-
if (isSingleQuoteString(text) || isDoubleQuoteString(text)) {
32-
return text.substring(1, text.length - 1);
33-
}
34-
35-
return text;
36-
}
37-
3823
export async function getDefinition(
3924
documentParserTreeManager: DocumentParserTreeManager,
4025
definitionParams: DefinitionParams

server/src/core/diagnostics.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @fileoverview Handles scanning and providing diagnostics regarding an Apache Dispatcher Config file (e.g., duplicate properties).
3+
* @author Darian Benam <[email protected]>
4+
*/
5+
6+
import { PROPERTY_PREFIX_CHARACTER } from "@language-server/constants/trigger-character";
7+
import { ApacheDispatcherConfigToken } from "@language-server/core/tree-sitter";
8+
import { convertValueToMd5Hash } from "@language-server/utils/crypto";
9+
import { getSyntaxNodeRange } from "@language-server/utils/range";
10+
import { removeOuterQuotes } from "@language-server/utils/string";
11+
import { Diagnostic, DiagnosticSeverity, Range } from "vscode-languageserver";
12+
import Parser = require("web-tree-sitter");
13+
14+
function createDuplicatePropertyDiagnostic(
15+
propertyName: string,
16+
range: Range,
17+
scopeId: string
18+
): Diagnostic {
19+
return {
20+
message: `The property '${propertyName}' is already defined in the current scope (Scope ID: ${scopeId}).`,
21+
range: range,
22+
severity: DiagnosticSeverity.Warning
23+
};
24+
}
25+
26+
function createDuplicateStringDiagnostic(
27+
stringValue: string,
28+
range: Range,
29+
scopeId: string
30+
): Diagnostic {
31+
return {
32+
message: `The string '${removeOuterQuotes(stringValue)}' is already defined in the current scope (Scope ID: ${scopeId}).`,
33+
range: range,
34+
severity: DiagnosticSeverity.Warning
35+
};
36+
}
37+
38+
function getSyntaxNodeScopeIdentifier(syntaxNode: Parser.SyntaxNode | null): string {
39+
return syntaxNode === null
40+
? "root"
41+
: `${syntaxNode.startPosition.row}`;
42+
}
43+
44+
function* buildSyntaxNodeScopeMapRecursively(
45+
syntaxNode: Parser.SyntaxNode,
46+
identifier: string
47+
): Generator<[ string, Parser.SyntaxNode ], void, unknown> {
48+
const scopeIdentifier: string = syntaxNode.type === ApacheDispatcherConfigToken.ScopedProperty
49+
? getSyntaxNodeScopeIdentifier(syntaxNode)
50+
: identifier;
51+
52+
if (
53+
syntaxNode.type === ApacheDispatcherConfigToken.PropertyName ||
54+
syntaxNode.type === ApacheDispatcherConfigToken.String
55+
) {
56+
yield [ scopeIdentifier, syntaxNode ];
57+
}
58+
59+
for (const childSyntaxNode of syntaxNode.children) {
60+
yield* buildSyntaxNodeScopeMapRecursively(childSyntaxNode, scopeIdentifier);
61+
}
62+
}
63+
64+
export function getDocumentParseTreeDiagnostics(
65+
documentParseTree: Parser.Tree | undefined
66+
): Diagnostic[] | undefined {
67+
if (documentParseTree === undefined) {
68+
return undefined;
69+
}
70+
71+
const diagnostics: Diagnostic[] = [];
72+
const rootNode: Parser.SyntaxNode = documentParseTree.rootNode;
73+
const syntaxNodeScopeMap = new Map<string, Parser.SyntaxNode[]>();
74+
75+
for (const [ scopeKey, node ] of buildSyntaxNodeScopeMapRecursively(rootNode, "global")) {
76+
if (!syntaxNodeScopeMap.has(scopeKey)) {
77+
syntaxNodeScopeMap.set(scopeKey, []);
78+
}
79+
80+
syntaxNodeScopeMap.get(scopeKey)!.push(node);
81+
}
82+
83+
syntaxNodeScopeMap.forEach(function(currentScopeSyntaxNodes: Parser.SyntaxNode[], rawScopeId: string) {
84+
const scopeIdMd5Hash: string = convertValueToMd5Hash(rawScopeId);
85+
const propertySyntaxNodeOccurrences: Map<string, Parser.SyntaxNode[]> = new Map();
86+
const stringSyntaxNodeOccurrences: Map<string, Parser.SyntaxNode[]> = new Map();
87+
88+
for (const syntaxNode of currentScopeSyntaxNodes) {
89+
const syntaxNodeTextValue: string = syntaxNode.text.trim();
90+
91+
// Ignore properties that are just '/' as we'll assume the user hasn't finished typing
92+
if (syntaxNodeTextValue === PROPERTY_PREFIX_CHARACTER) {
93+
continue;
94+
}
95+
96+
const currentSyntaxNodeRange: Range = getSyntaxNodeRange(syntaxNode);
97+
let syntaxNodeOccurrences: Parser.SyntaxNode[];
98+
99+
if (syntaxNode.type === ApacheDispatcherConfigToken.PropertyName) {
100+
syntaxNodeOccurrences = propertySyntaxNodeOccurrences.get(syntaxNodeTextValue) ?? [];
101+
syntaxNodeOccurrences.push(syntaxNode);
102+
103+
propertySyntaxNodeOccurrences.set(syntaxNodeTextValue, syntaxNodeOccurrences);
104+
105+
if (syntaxNodeOccurrences.length === 2) {
106+
diagnostics.push(createDuplicatePropertyDiagnostic(syntaxNodeTextValue, getSyntaxNodeRange(syntaxNodeOccurrences[0]), scopeIdMd5Hash));
107+
}
108+
109+
if (syntaxNodeOccurrences.length > 1) {
110+
diagnostics.push(createDuplicatePropertyDiagnostic(syntaxNodeTextValue, currentSyntaxNodeRange, scopeIdMd5Hash));
111+
}
112+
} else if (syntaxNode.type === ApacheDispatcherConfigToken.String) {
113+
const syntaxNodeParent: Parser.SyntaxNode | null = syntaxNode.parent;
114+
115+
if (
116+
syntaxNodeParent !== null &&
117+
syntaxNodeParent.type === ApacheDispatcherConfigToken.Property
118+
) {
119+
continue;
120+
}
121+
122+
const stringValueWithoutQuotes: string = removeOuterQuotes(syntaxNodeTextValue);
123+
124+
syntaxNodeOccurrences = stringSyntaxNodeOccurrences.get(stringValueWithoutQuotes) ?? [];
125+
syntaxNodeOccurrences.push(syntaxNode);
126+
127+
stringSyntaxNodeOccurrences.set(stringValueWithoutQuotes, syntaxNodeOccurrences);
128+
129+
if (syntaxNodeOccurrences.length === 2) {
130+
diagnostics.push(createDuplicateStringDiagnostic(syntaxNodeTextValue, getSyntaxNodeRange(syntaxNodeOccurrences[0]), scopeIdMd5Hash));
131+
}
132+
133+
if (syntaxNodeOccurrences.length > 1) {
134+
diagnostics.push(createDuplicateStringDiagnostic(syntaxNodeTextValue, currentSyntaxNodeRange, scopeIdMd5Hash));
135+
}
136+
}
137+
}
138+
});
139+
140+
return diagnostics;
141+
}

server/src/core/document-parser-tree-manager.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@ import {
88
loadApacheDispatcherConfigTreeSitterLanguage,
99
tokenizeTextDocument
1010
} from "@language-server/core/tree-sitter";
11+
import { getSyntaxNodeRange } from "@language-server/utils/range";
1112
import { DocumentSymbol, SymbolKind, TextDocuments } from "vscode-languageserver";
1213
import { Range, TextDocument } from "vscode-languageserver-textdocument";
1314
import Parser = require("web-tree-sitter");
1415

15-
type ApacheDispatcherConfigDocoumentSymbol = {
16+
type ApacheDispatcherConfigDocumentSymbol = {
1617
node: Parser.SyntaxNode;
1718
parentSymbols: DocumentSymbol[];
1819
}
1920

2021
export async function waitForDocumentParserTreeManagerInitialization(
21-
documentParserTreeManager: DocumentParserTreeManager
22+
documentParserTreeManager: DocumentParserTreeManager | undefined
2223
): Promise<void> {
2324
return new Promise(function(resolve: (value: void) => void) {
2425
setTimeout(function() {
@@ -68,27 +69,8 @@ export class DocumentParserTreeManager {
6869
selectionSyntaxNode: Parser.SyntaxNode,
6970
kind: SymbolKind
7071
): DocumentSymbol {
71-
const parentTokenRange: Range = {
72-
start: {
73-
line: parentSyntaxNode.startPosition.row,
74-
character: parentSyntaxNode.startPosition.column
75-
},
76-
end: {
77-
line: parentSyntaxNode.endPosition.row,
78-
character: parentSyntaxNode.endPosition.column
79-
}
80-
};
81-
82-
const selectionRange: Range = {
83-
start: {
84-
line: selectionSyntaxNode.startPosition.row,
85-
character: selectionSyntaxNode.startPosition.column
86-
},
87-
end: {
88-
line: selectionSyntaxNode.endPosition.row,
89-
character: selectionSyntaxNode.endPosition.column
90-
}
91-
};
72+
const parentTokenRange: Range = getSyntaxNodeRange(parentSyntaxNode);
73+
const selectionRange: Range = getSyntaxNodeRange(selectionSyntaxNode);
9274

9375
return {
9476
name: selectionSyntaxNode.text,
@@ -98,7 +80,6 @@ export class DocumentParserTreeManager {
9880
};
9981
}
10082

101-
10283
private getApacheDispatcherConfigTokenSymbolKind(tokenType: ApacheDispatcherConfigToken): SymbolKind {
10384
switch (tokenType) {
10485
case ApacheDispatcherConfigToken.Function:
@@ -122,15 +103,15 @@ export class DocumentParserTreeManager {
122103
}
123104

124105
const rootSymbols: DocumentSymbol[] = [];
125-
const documentSymbolsStack: ApacheDispatcherConfigDocoumentSymbol[] = [
106+
const documentSymbolsStack: ApacheDispatcherConfigDocumentSymbol[] = [
126107
{
127108
node: syntaxTree.rootNode,
128109
parentSymbols: rootSymbols
129110
}
130111
];
131112

132113
while (documentSymbolsStack.length > 0) {
133-
const documentSymbol: ApacheDispatcherConfigDocoumentSymbol | undefined = documentSymbolsStack.pop();
114+
const documentSymbol: ApacheDispatcherConfigDocumentSymbol | undefined = documentSymbolsStack.pop();
134115

135116
if (documentSymbol === undefined || documentSymbol.node === undefined) {
136117
continue;
@@ -194,14 +175,16 @@ export class DocumentParserTreeManager {
194175
return rootSymbols;
195176
}
196177

197-
public updateParseTree(document: TextDocument): void {
178+
public updateParseTree(document: TextDocument): boolean {
198179
if (this.treeSitterParser === undefined) {
199-
throw new Error("Tree-sitter parser has not been initialized!");
180+
return false;
200181
}
201182

202-
const documentUri = document.uri;
183+
const documentUri: string = document.uri;
203184
const textDocumentParserTree: Parser.Tree = tokenizeTextDocument(this.treeSitterParser, document);
204185

205186
this.documentParseTree.set(documentUri, textDocumentParserTree);
187+
188+
return true;
206189
}
207190
}

server/src/server.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { AUTOCOMPLETION_TRIGGER_CHARACTERS, handleAutoCompletion } from "@language-server/core/autocompletion";
77
import { getDefinition } from "@language-server/core/definition-provider";
8+
import { getDocumentParseTreeDiagnostics } from "@language-server/core/diagnostics";
89
import { DocumentParserTreeManager, waitForDocumentParserTreeManagerInitialization } from "@language-server/core/document-parser-tree-manager";
910
import { DocumentationManager } from "@language-server/core/documentation-manager";
1011
import { handleHover } from "@language-server/core/hover-provider";
@@ -16,6 +17,7 @@ import {
1617
Connection,
1718
DefinitionLink,
1819
DefinitionParams,
20+
Diagnostic,
1921
DidChangeConfigurationNotification,
2022
DocumentSymbol,
2123
DocumentSymbolParams,
@@ -29,15 +31,40 @@ import {
2931
TextDocuments,
3032
createConnection
3133
} from "vscode-languageserver/node";
34+
import Parser = require("web-tree-sitter");
3235

3336
const CONNECTION: Connection = createConnection(ProposedFeatures.all);
3437
const DOCUMENT_MANAGER: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
3538

3639
let hasConfigurationCapability: boolean = false;
3740

38-
let documentParserTreeManager: DocumentParserTreeManager;
41+
let documentParserTreeManager: DocumentParserTreeManager | undefined;
3942
let documentationManager: DocumentationManager;
4043

44+
function updateDocumentParseTreeAndSendDiagnostics(
45+
document: TextDocument,
46+
sendDiagnostics: boolean = true
47+
): void {
48+
if (documentParserTreeManager === undefined) {
49+
return;
50+
}
51+
52+
const parseTreeUpdated: boolean = documentParserTreeManager.updateParseTree(document);
53+
54+
if (parseTreeUpdated && sendDiagnostics) {
55+
const documentUri: string = document.uri;
56+
const documentTokenTree: Parser.Tree | undefined = documentParserTreeManager.getDocumentTokenTree(documentUri);
57+
const currentDocumentDiagnostics: Diagnostic[] | undefined = getDocumentParseTreeDiagnostics(documentTokenTree);
58+
59+
if (currentDocumentDiagnostics !== undefined) {
60+
CONNECTION.sendDiagnostics({
61+
uri: documentUri,
62+
diagnostics: currentDocumentDiagnostics
63+
});
64+
}
65+
}
66+
}
67+
4168
CONNECTION.onInitialize(async function(initializeParams: InitializeParams): Promise<InitializeResult> {
4269
console.info("Initializing language server...");
4370

@@ -89,7 +116,7 @@ CONNECTION.onCompletion(
89116
async (textDocumentPositionParams: TextDocumentPositionParams): Promise<CompletionItem[]> => {
90117
return await handleAutoCompletion(
91118
DOCUMENT_MANAGER,
92-
documentParserTreeManager,
119+
documentParserTreeManager!,
93120
documentationManager,
94121
textDocumentPositionParams
95122
);
@@ -115,7 +142,7 @@ CONNECTION.onDocumentSymbol(async function(documentSymbolParams: DocumentSymbolP
115142
const document: TextDocument | undefined = DOCUMENT_MANAGER.get(documentSymbolParams.textDocument.uri);
116143

117144
if (document !== undefined) {
118-
return documentParserTreeManager.getDocumentSymbols(document.uri);
145+
return documentParserTreeManager!.getDocumentSymbols(document.uri);
119146
}
120147

121148
return [];
@@ -125,19 +152,19 @@ CONNECTION.onHover(
125152
async (textDocumentPositionParams: TextDocumentPositionParams): Promise<Hover | null> => {
126153
return await handleHover(
127154
DOCUMENT_MANAGER,
128-
documentParserTreeManager,
155+
documentParserTreeManager!,
129156
documentationManager,
130157
textDocumentPositionParams
131158
);
132159
}
133160
);
134161

135-
DOCUMENT_MANAGER.onDidOpen(function(event: TextDocumentChangeEvent<TextDocument>) {
136-
documentParserTreeManager?.updateParseTree(event.document);
162+
DOCUMENT_MANAGER.onDidOpen(function(event: TextDocumentChangeEvent<TextDocument>): void {
163+
updateDocumentParseTreeAndSendDiagnostics(event.document, false);
137164
});
138165

139-
DOCUMENT_MANAGER.onDidChangeContent(async function(event: TextDocumentChangeEvent<TextDocument>): Promise<void> {
140-
documentParserTreeManager?.updateParseTree(event.document);
166+
DOCUMENT_MANAGER.onDidChangeContent(function(event: TextDocumentChangeEvent<TextDocument>): void {
167+
updateDocumentParseTreeAndSendDiagnostics(event.document);
141168
});
142169

143170
DOCUMENT_MANAGER.listen(CONNECTION);

server/src/utils/crypto.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @fileoverview Utility functions for dealing with cryptography.
3+
* @author Darian Benam <[email protected]>
4+
*/
5+
6+
import { createHash } from "crypto";
7+
8+
export function convertValueToMd5Hash(value: string): string {
9+
return createHash("md5").update(value).digest("hex");
10+
}

0 commit comments

Comments
 (0)