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
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@
}
}
},
"nwscript-ee-lsp.definition": {
"type": "object",
"properties": {
"preferImplementation": {
"type": "boolean",
"default": true,
"description": "When using Go to Definition on a function, prefer jumping to the implementation (with body) over the forward declaration. When disabled, jumps to the first occurrence found."
}
}
},
"nwscript-ee-lsp.compiler": {
"type": "object",
"properties": {
Expand Down
Binary file modified server/resources/compiler/linux/nwn_script_comp
Binary file not shown.
Binary file modified server/resources/compiler/mac/nwn_script_comp
Binary file not shown.
Binary file modified server/resources/compiler/windows/nwn_script_comp.exe
Binary file not shown.
21 changes: 18 additions & 3 deletions server/src/Providers/Builders/CompletionItemBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,38 @@ import Builder from "./Builder";

export default class CompletionItemBuilder extends Builder {
public static buildResolvedItem(item: CompletionItem, serverConfig: ServerConfiguration): CompletionItem {
const isAutoImport = item.data?.autoImport;
const params = isAutoImport ? item.data.params : item.data;

if (serverConfig.completion.addParamsToFunctions && item.kind === CompletionItemKind.Function) {
const params = item.data as FunctionParamComplexToken[];
const typedParams = params as FunctionParamComplexToken[];

return {
label: `${item.label}(${params.reduce((acc, param, index) => {
label: `${item.label}(${typedParams.reduce((acc, param, index) => {
return `${acc}${this.handleLanguageType(param.valueType)} ${param.identifier}${
index === params.length - 1 ? "" : ", "
index === typedParams.length - 1 ? "" : ", "
}`;
}, "")})`,
kind: item.kind,
detail: item.detail,
data: item.data,
};
}

return item;
}

public static buildAutoImportItem(token: ComplexToken, sourceFileKey: string, requestingUri: string): CompletionItem {
const item = this.buildItem(token);
item.data = {
autoImport: { sourceFileKey, requestingUri },
params: item.data,
};
item.sortText = `1_${item.label}`;
item.detail = `${item.detail || ""} (auto-import from ${sourceFileKey})`;
return item;
}

public static buildItem(token: ComplexToken): CompletionItem {
if (this.isConstantToken(token)) {
return this.buildConstantItem(token);
Expand Down
87 changes: 87 additions & 0 deletions server/src/Providers/CodeActionProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { CodeAction, CodeActionKind, CodeActionParams, TextEdit } from "vscode-languageserver";

import type { ServerManager } from "../ServerManager";
import { computeIncludeInsertPosition } from "../Utils/includeInsertPosition";
import Provider from "./Provider";

export default class CodeActionProvider extends Provider {
constructor(server: ServerManager) {
super(server);

this.server.connection.onCodeAction((params) => this.exceptionsWrapper(this.providerHandler(params), []));
}

private providerHandler(params: CodeActionParams) {
return () => {
const {
textDocument: { uri },
context,
} = params;

if (!context.diagnostics.length) return [];

const liveDocument = this.server.liveDocumentsManager.get(uri);
const document = this.server.documentsCollection.getFromUri(uri);
if (!liveDocument || !document) return [];

const actions: CodeAction[] = [];
const text = liveDocument.getText();
const lines = text.split("\n");
const children = document.getChildren();
const ownKey = document.getKey();
const excludedKeys = new Set([ownKey, ...children]);
const isQueueSrc = uri.includes("queue_src");

// Build a set of identifiers already in scope to avoid false positives
const inScopeIdentifiers = new Set<string>();
document.getGlobalComplexTokens().forEach((t) => inScopeIdentifiers.add(t.identifier));
document.getGlobalStructComplexTokens().forEach((t) => inScopeIdentifiers.add(t.identifier));
this.getStandardLibComplexTokens().forEach((t) => inScopeIdentifiers.add(t.identifier));

for (const diagnostic of context.diagnostics) {
const line = lines[diagnostic.range.start.line];
if (!line) continue;

// Diagnostics may span the entire line, so extract all identifiers
// from the line and filter to those not already in scope.
const lineIdentifiers = (line.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []).filter((id) => !inScopeIdentifiers.has(id));
if (lineIdentifiers.length === 0) continue;

const identifiersSet = new Set(lineIdentifiers);
const suggestedKeys = new Set<string>();

this.server.documentsCollection.forEach((doc) => {
const docKey = doc.getKey();

if (doc.base || excludedKeys.has(docKey)) return;
if (suggestedKeys.has(docKey)) return;

const docIsQueueSrc = doc.uri.includes("queue_src");
if (isQueueSrc !== docIsQueueSrc) return;

const hasSymbol =
doc.complexTokens.some((token) => identifiersSet.has(token.identifier)) ||
doc.structComplexTokens.some((token) => identifiersSet.has(token.identifier));

if (hasSymbol) {
suggestedKeys.add(docKey);
const insertPosition = computeIncludeInsertPosition(text);
actions.push({
title: `Add #include "${docKey}"`,
kind: CodeActionKind.QuickFix,
diagnostics: [diagnostic],
edit: {
changes: {
[uri]: [TextEdit.insert(insertPosition, `#include "${docKey}"\n`)],
},
},
command: { title: "Recompile", command: "nwscript-ee-lsp.recompile", arguments: [uri] },
});
}
});
}

return actions;
};
}
}
65 changes: 62 additions & 3 deletions server/src/Providers/CompletionItemsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import { CompletionParams } from "vscode-languageserver";
import { CompletionItem, CompletionParams, TextEdit } from "vscode-languageserver";

import type { ServerManager } from "../ServerManager";
import { CompletionItemBuilder } from "./Builders";
import { LocalScopeTokenizationResult } from "../Tokenizer/Tokenizer";
import { TriggerCharacters } from ".";
import { Document } from "../Documents";
import { LanguageTypes } from "../Tokenizer/constants";
import { computeIncludeInsertPosition } from "../Utils/includeInsertPosition";
import Provider from "./Provider";

export default class CompletionItemsProvider extends Provider {
constructor(server: ServerManager) {
super(server);

this.server.connection.onCompletion((params) => this.exceptionsWrapper(this.providerHandler(params)));
this.server.connection.onCompletionResolve((item) => this.exceptionsWrapper(() => CompletionItemBuilder.buildResolvedItem(item, this.server.config), item));
this.server.connection.onCompletionResolve((item) =>
this.exceptionsWrapper(() => {
const resolved = CompletionItemBuilder.buildResolvedItem(item, this.server.config);

if (item.data?.autoImport) {
const { sourceFileKey, requestingUri } = item.data.autoImport;
const liveDocument = this.server.liveDocumentsManager.get(requestingUri);
if (liveDocument) {
const insertPosition = computeIncludeInsertPosition(liveDocument.getText());
resolved.additionalTextEdits = [TextEdit.insert(insertPosition, `#include "${sourceFileKey}"\n`)];
resolved.command = { title: "Recompile", command: "nwscript-ee-lsp.recompile", arguments: [requestingUri] };
}
}

return resolved;
}, item),
);
}

private providerHandler(params: CompletionParams) {
Expand Down Expand Up @@ -46,7 +63,10 @@ export default class CompletionItemsProvider extends Provider {
return document.getGlobalStructComplexTokens().map((token) => CompletionItemBuilder.buildItem(token));
}

return this.getGlobalScopeCompletionItems(document, localScope).concat(this.getLocalScopeCompletionItems(localScope)).concat(this.getStandardLibCompletionItems());
return this.getGlobalScopeCompletionItems(document, localScope)
.concat(this.getLocalScopeCompletionItems(localScope))
.concat(this.getStandardLibCompletionItems())
.concat(this.getAutoImportCompletionItems(document, localScope, uri));
};
}

Expand All @@ -69,4 +89,43 @@ export default class CompletionItemsProvider extends Provider {
private getStandardLibCompletionItems() {
return this.getStandardLibComplexTokens().map((token) => CompletionItemBuilder.buildItem(token));
}

private getAutoImportCompletionItems(document: Document, localScope: LocalScopeTokenizationResult, uri: string) {
const children = document.getChildren();
const ownKey = document.getKey();
const excludedKeys = new Set([ownKey, ...children]);

const inScopeIdentifiers = new Set<string>();
document
.getGlobalComplexTokens(
[],
localScope.functionsComplexTokens.map((t) => t.identifier),
)
.forEach((t) => inScopeIdentifiers.add(t.identifier));
localScope.functionsComplexTokens.forEach((t) => inScopeIdentifiers.add(t.identifier));
localScope.functionVariablesComplexTokens.forEach((t) => inScopeIdentifiers.add(t.identifier));
this.getStandardLibComplexTokens().forEach((t) => inScopeIdentifiers.add(t.identifier));

const isQueueSrc = uri.includes("queue_src");
const items: CompletionItem[] = [];

this.server.documentsCollection.forEach((doc) => {
const docKey = doc.getKey();

if (doc.base || excludedKeys.has(docKey)) return;

const docIsQueueSrc = doc.uri.includes("queue_src");
if (isQueueSrc !== docIsQueueSrc) return;

const seenInDoc = new Set<string>();
doc.complexTokens.forEach((token) => {
if (!inScopeIdentifiers.has(token.identifier) && !seenInDoc.has(token.identifier)) {
seenInDoc.add(token.identifier);
items.push(CompletionItemBuilder.buildAutoImportItem(token, docKey, uri));
}
});
});

return items;
}
}
60 changes: 42 additions & 18 deletions server/src/Providers/DiagnosticsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,27 @@ export default class DiagnoticsProvider extends Provider {

private generateDiagnostics(uris: string[], files: FilesDiagnostics, severity: DiagnosticSeverity) {
return (line: string) => {
const uri = uris.find((uri) => basename(fileURLToPath(uri)) === lineFilename.exec(line)![2]);

if (uri) {
const linePosition = Number(lineNumber.exec(line)![1]) - 1;
const diagnostic = {
severity,
range: {
start: { line: linePosition, character: 0 },
end: { line: linePosition, character: Number.MAX_VALUE },
},
message: lineMessage.exec(line)![2].trim(),
};

files[uri].push(diagnostic);
const lineFilenameMatch = lineFilename.exec(line);
if (lineFilenameMatch) {
const reportedFileName = lineFilenameMatch[2];
const uri = uris.find((uri) => basename(fileURLToPath(uri)) === reportedFileName);
if (uri) {
const lineNumberMatch = lineNumber.exec(line);
const lineMessageMatch = lineMessage.exec(line);
if (lineNumberMatch && lineMessageMatch) {
const linePosition = Number(lineNumberMatch[1]) - 1;
const diagnostic = {
severity,
range: {
start: { line: linePosition, character: 0 },
end: { line: linePosition, character: Number.MAX_VALUE },
},
message: lineMessageMatch[2].trim(),
};

files[uri].push(diagnostic);
}
}
}
};
}
Expand Down Expand Up @@ -101,7 +108,10 @@ export default class DiagnoticsProvider extends Provider {
// The compiler command:
// - y; continue on error
// - s; dry run
const args = ["-y", "-s"];
// - n; no entry point required (for include files)
// - E; collect and report all errors (not just the first)
// Note: -E requires compiler binary with ABI v2+
const args = ["-y", "-s", "-n", "-E"];
if (Boolean(nwnHome)) {
args.push("--userdirectory");
args.push(`"${nwnHome}"`);
Expand All @@ -114,12 +124,25 @@ export default class DiagnoticsProvider extends Provider {
} else if (verbose) {
this.server.logger.info("Trying to resolve Neverwinter Nights installation directory automatically.");
}
if (children.length > 0) {
// Collect directories from ALL indexed documents so the compiler can
// resolve the full transitive include chain, not just direct children.
const allDirs: Set<string> = new Set();
this.server.documentsCollection.forEach((doc) => {
if (doc.uri && doc.uri.startsWith("file://")) {
allDirs.add(dirname(fileURLToPath(doc.uri)));
}
});
// Also add the compiled file's own directory
allDirs.add(dirname(fileURLToPath(uri)));
if (allDirs.size > 0) {
args.push("--dirs");
args.push(`"${[...new Set(uris.map((uri) => dirname(fileURLToPath(uri))))].join(",")}"`);
args.push(`"${[...allDirs].join(",")}"`);
}

const filePath = fileURLToPath(uri);
this.server.logger.info(`Compiling file: ${filePath}`);
args.push("-c");
args.push(`"${fileURLToPath(uri)}"`);
args.push(`"${filePath}"`);

let stdout = "";
let stderr = "";
Expand Down Expand Up @@ -186,6 +209,7 @@ export default class DiagnoticsProvider extends Provider {
for (const [uri, diagnostics] of Object.entries(files)) {
this.server.connection.sendDiagnostics({ uri, diagnostics });
}

resolve(true);
});
});
Expand Down
43 changes: 37 additions & 6 deletions server/src/Providers/GotoDefinitionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TextDocument } from "vscode-languageserver-textdocument";

import type { OwnedComplexTokens, OwnedStructComplexTokens } from "../Documents/Document";
import type { ServerManager } from "../ServerManager";
import type { ComplexToken } from "../Tokenizer/types";
import type { ComplexToken, FunctionComplexToken } from "../Tokenizer/types";
import { Document } from "../Documents";
import Provider from "./Provider";

Expand Down Expand Up @@ -71,12 +71,43 @@ export default class GotoDefinitionProvider extends Provider {
tokensWithRef.push({ owner: localStandardLibDefinitions?.uri, tokens: localStandardLibDefinitions?.complexTokens });
}

loop: for (let i = 0; i < tokensWithRef.length; i++) {
ref = tokensWithRef[i];
if (this.server.config.definition.preferImplementation) {
// Two-pass: prefer function definitions (with bodies) over forward declarations
let fallbackToken: ComplexToken | undefined;
let fallbackRef: OwnedComplexTokens | undefined;

loop: for (let i = 0; i < tokensWithRef.length; i++) {
ref = tokensWithRef[i];

for (const candidate of ref?.tokens ?? []) {
if (candidate.identifier !== rawContent) continue;

const funcCandidate = candidate as FunctionComplexToken;
if (funcCandidate.tokenType === CompletionItemKind.Function && funcCandidate.isForwardDeclaration) {
if (!fallbackToken) {
fallbackToken = candidate;
fallbackRef = ref;
}
} else {
token = candidate;
break loop;
}
}
}

token = ref?.tokens.find((candidate) => candidate.identifier === rawContent);
if (token) {
break loop;
if (!token && fallbackToken) {
token = fallbackToken;
ref = fallbackRef;
}
} else {
// Jump to first occurrence found
loop: for (let i = 0; i < tokensWithRef.length; i++) {
ref = tokensWithRef[i];

token = ref?.tokens.find((candidate) => candidate.identifier === rawContent);
if (token) {
break loop;
}
}
}
break;
Expand Down
Loading