Skip to content
Merged
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The language server is included. You can work on a single resource or an entire
| Navigation and rename | Follow definitions and references across your workspace and rename supported symbols. |
| Formatting | Format Lua with shared project settings in `qbxlint.toml`. |
| Syntax and editor hints | Read CfxLua backtick hashes, LuaCATS annotations, semantic highlighting, signature help and parameter hints. |
| Annotation typing | Press Enter after a `---@param` line to start the next one with `---@`; Enter on a bare `---@` clears it. Typing a letter after `---@` lists the tags, previewing the rest of the selected one in gray; Tab accepts it. |
| Framework callbacks | Complete QB-Core/ESX callback names, find server handlers and see payload hints from local Lua code. |
| Snippet browser | Preview and insert Lua recipes, manifest templates, and personal or workspace snippets. |
| Resource wizard | Preview and create a new resource from a plain Lua, ox_lib or Qbox starter. |
Expand Down Expand Up @@ -426,6 +427,10 @@ The extension registers itself as the default Lua formatter. Without a `qbxlint.
formatter wraps lines longer than 120 columns; projects that keep long lines should raise
`line_width` before enabling format on save, or the first save reflows every long statement.

`quote_style` also sets the quotes that built-in snippets, completions and new resource manifests
write. With `preserve`, snippets follow the quote most strings of the file already use, and new
resource manifests use single quotes.

See the [configuration reference](https://github.com/Qbox-project/qbx-lua/blob/main/docs/reference.md)
and [rule list](https://github.com/Qbox-project/qbx-lua/blob/main/docs/rules.md).

Expand All @@ -438,7 +443,7 @@ Search for `qbxLua` in VS Code Settings.
| `qbxLua.library` | `[]` | Extra dependency folders to index. |
| `qbxLua.diagnostics.enable` | `true` | Show diagnostics. |
| `qbxLua.diagnostics.workspace` | `true` | Include files that are not open. |
| `qbxLua.diagnostics.rules` | `{}` | Override rule levels: `off`, `hint`, `info`, `warning` or `error`. |
| `qbxLua.diagnostics.rules` | `{}` | Rule levels: `off`, `hint`, `info`, `warning` or `error`. The levels a `qbxlint.toml` sets take precedence. |
| `qbxLua.inlayHints.enable` | `true` | Show parameter names beside literal arguments. |
| `qbxLua.semanticTokens.enable` | `true` | Enable semantic highlighting. |
| `qbxLua.warnAboutOtherLuaExtensions` | `true` | Warn about other active Lua language servers. |
Expand Down
3 changes: 2 additions & 1 deletion docs/resource-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
generates exactly `fxmanifest.lua`, `shared/config.lua`, `client/main.lua` and
`server/main.lua`. The wizard previews all four files before creating a new
folder. Client and server entry files contain comments; the shared file creates
an empty `Config` table in each runtime.
an empty `Config` table in each runtime. Manifest strings use the workspace's
`quote_style` from `qbxlint.toml`, and single quotes when it is `preserve` or unset.

| Template | Shared imports, in load order | Direct resource dependencies |
| --- | --- | --- |
Expand Down
19 changes: 11 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"error"
]
},
"markdownDescription": "Override rule levels, e.g. `{ \"unused-argument\": \"off\", \"fivem/citizen-prefix\": \"warning\" }`. A `qbxlint.toml` in the workspace is honoured as well, so the editor and CI stay in sync."
"markdownDescription": "Rule levels, e.g. `{ \"unused-argument\": \"off\", \"fivem/citizen-prefix\": \"warning\" }`. The levels a `qbxlint.toml` in the workspace sets take precedence, so the editor and CI stay in sync."
},
"qbxLua.inlayHints.enable": {
"type": "boolean",
Expand Down Expand Up @@ -351,6 +351,13 @@
"injectTo": [
"source.lua"
]
},
{
"scopeName": "luacats.documentation.injection",
"path": "./syntaxes/luacats-documentation.injection.json",
"injectTo": [
"source.lua"
]
}
],
"jsonValidation": [
Expand All @@ -362,16 +369,12 @@
"url": "./snippets/qbx-lua-snippets.schema.json"
}
],
"snippets": [
{
"language": "lua",
"path": "./snippets/fxmanifest.json"
}
],
"configurationDefaults": {
"[lua]": {
"editor.semanticHighlighting.enabled": true,
"editor.defaultFormatter": "qbox.qbx-lua"
"editor.defaultFormatter": "qbox.qbx-lua",
"editor.formatOnType": true,
"editor.suggest.preview": true
}
},
"semanticTokenScopes": [
Expand Down
36 changes: 34 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as vscode from 'vscode';
import { LanguageClient, LanguageClientOptions, ServerOptions, State, TransportKind } from 'vscode-languageclient/node';
import { luaQuote } from './luaQuote';
import { registerManifestSnippetCompletions } from './manifestSnippets';
import { ResourceControls } from './resourceControls';
import { ReferenceBrowser } from './referenceBrowser';
import { SnippetBrowser } from './snippetBrowser';
Expand Down Expand Up @@ -45,7 +47,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<import
};
const referenceBrowser = new ReferenceBrowser(context.extensionUri, request);
const snippetBrowser = new SnippetBrowser(context.extensionUri, context.globalStorageUri, request);
context.subscriptions.push(referenceBrowser, snippetBrowser);
context.subscriptions.push(referenceBrowser, snippetBrowser, registerManifestSnippetCompletions(context.extensionUri, request));
let resourceWizard: import('./resourceWizard.js').ResourceWizard | undefined;
let wizardDisposed = false;
context.subscriptions.push({ dispose: () => { wizardDisposed = true; resourceWizard?.dispose(); } });
Expand Down Expand Up @@ -97,7 +99,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<import
vscode.commands.registerCommand('qbxLua.resources.create', async (uri?: unknown) => {
const { ResourceWizard } = await import('./resourceWizard.js');
if (wizardDisposed) { return; }
resourceWizard ??= new ResourceWizard();
resourceWizard ??= new ResourceWizard(() => luaQuote(request));
await resourceWizard.show(uri);
}),
vscode.commands.registerCommand('qbxLua.resources.details', async (uri?: unknown) => {
Expand Down Expand Up @@ -141,6 +143,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<import
void refreshStatus();
}
}),
vscode.workspace.onDidChangeTextDocument(suggestAnnotationTags),
);

await start(context);
Expand All @@ -149,6 +152,22 @@ export async function activate(context: vscode.ExtensionContext): Promise<import
return registerAssistantTools(context, request);
}

/**
* Opens the annotation tag list when the first letter is typed after `---@`. The `---@` that Enter
* adds to an annotation block opens no list, so Enter on it can still clear it, and VS Code does
* not suggest while typing in comments.
*/
function suggestAnnotationTags(event: vscode.TextDocumentChangeEvent): void {
const [change] = event.contentChanges;
if (event.document.languageId !== 'lua' || event.contentChanges.length !== 1 || !/^[A-Za-z]$/.test(change.text)) {
return;
}
const typed = event.document.lineAt(change.range.start.line).text.slice(0, change.range.start.character + 1);
if (/^[ \t]*---@[A-Za-z]$/.test(typed) && vscode.window.activeTextEditor?.document === event.document) {
void vscode.commands.executeCommand('editor.action.triggerSuggest');
}
}

export async function deactivate(): Promise<void> {
await client?.stop();
client = undefined;
Expand Down Expand Up @@ -206,6 +225,19 @@ async function start(context: vscode.ExtensionContext): Promise<void> {
};

client = new LanguageClient('qbxLua', 'Qbox Lua', serverOptions, clientOptions);
// Lists the commands VS Code runs for the server, the way rust-analyzer's client does, so a
// completed callback wrapper call can reopen suggestions with the registered names.
client.registerFeature({
fillClientCapabilities(capabilities) {
const experimental = (capabilities.experimental ?? {}) as Record<string, unknown>;
capabilities.experimental = { ...experimental, commands: { commands: ['editor.action.triggerSuggest'] } };
},
initialize() {},
getState() {
return { kind: 'static' };
},
clear() {},
});
client.onDidChangeState((event) => {
if (event.newState === State.Running) {
void refreshStatus();
Expand Down
21 changes: 21 additions & 0 deletions src/luaQuote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type * as vscode from 'vscode';
import type { ReferenceRequest } from './referenceTypes';

export type LuaQuote = "'" | '"';

/**
* The quote that strings written into `uri` use, decided by the language server as for its own
* completions: the formatter's `quote_style`, or else the quote most strings of that open document
* use. `'` without a document, or when the server cannot answer.
*/
export async function luaQuote(request: ReferenceRequest, uri?: vscode.Uri): Promise<LuaQuote> {
try {
const quote = await request<unknown>('qbx/quote', uri?.scheme === 'file' ? { uri: uri.toString() } : null);
return quote === '"' ? '"' : "'";
} catch { return "'"; }
}

/** A snippet written with `'` around its strings, with `quote` around them instead. */
export function withQuote(body: string, quote: LuaQuote): string {
return quote === "'" ? body : body.replaceAll("'", '"');
}
32 changes: 32 additions & 0 deletions src/manifestSnippets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as vscode from 'vscode';
import { luaQuote, withQuote } from './luaQuote';
import type { ReferenceRequest } from './referenceTypes';
import { parseSnippetFile, type ParsedSnippet } from './snippetFormat';

/** The bundled manifest snippets, written with `'` around their strings. */
export async function loadManifestSnippets(extensionUri: vscode.Uri): Promise<ParsedSnippet[]> {
const bytes = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(extensionUri, 'snippets', 'fxmanifest.json'));
const parsed = parseSnippetFile(Buffer.from(bytes).toString('utf8'));
if (parsed.issues.length) { throw new Error(parsed.issues.join(' ')); }
return parsed.snippets;
}

/**
* Offers the bundled manifest snippets under their prefixes in Lua files, as a `snippets`
* contribution would, but with their strings in the quote of the document.
*/
export function registerManifestSnippetCompletions(extensionUri: vscode.Uri, request: ReferenceRequest): vscode.Disposable {
let snippets: Promise<ParsedSnippet[]> | undefined;
return vscode.languages.registerCompletionItemProvider({ language: 'lua' }, {
async provideCompletionItems(document) {
snippets ??= loadManifestSnippets(extensionUri);
const [loaded, quote] = await Promise.all([snippets, luaQuote(request, document.uri)]);
return loaded.flatMap((snippet) => snippet.prefix.map((prefix) => {
const item = new vscode.CompletionItem({ label: prefix, description: snippet.name }, vscode.CompletionItemKind.Snippet);
item.insertText = new vscode.SnippetString(withQuote(snippet.body, quote));
item.detail = snippet.description;
return item;
}));
},
});
}
2 changes: 1 addition & 1 deletion src/resourceScaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export class ResourceScaffolder {
}

private verifyPlan(plan: ResourcePlan): ResourcePlan {
const expected = createResourcePlan({ name: plan.name, templateId: plan.templateId });
const expected = createResourcePlan({ name: plan.name, templateId: plan.templateId, quote: plan.quote });
if (plan.files.length !== expected.files.length || plan.files.some((file, index) => file.path !== expected.files[index].path || file.content !== expected.files[index].content)) {
throw new Error('The resource files no longer match the template preview. Generate a new preview.');
}
Expand Down
29 changes: 19 additions & 10 deletions src/resourceTemplates.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LuaQuote } from './luaQuote';

export type ResourceTemplateId = 'lua' | 'ox_lib' | 'qbox';

export interface ResourceTemplate {
Expand All @@ -11,6 +13,7 @@ export interface ResourcePlan {
readonly name: string;
readonly templateId: ResourceTemplateId;
readonly templateLabel: string;
readonly quote: LuaQuote;
readonly dependencies: readonly string[];
readonly files: readonly { readonly path: string; readonly content: string }[];
}
Expand All @@ -32,35 +35,40 @@ export function resourceNameError(name: string): string | undefined {
return undefined;
}

/** Pure, deterministic starter contents. See docs/resource-templates.md for provenance. */
export function createResourcePlan(options: { name: string; templateId: ResourceTemplateId }): ResourcePlan {
/**
* Pure, deterministic starter contents, with `quote` around the manifest's strings (`'` by default).
* See docs/resource-templates.md for provenance.
*/
export function createResourcePlan(options: { name: string; templateId: ResourceTemplateId; quote?: LuaQuote }): ResourcePlan {
const invalidName = resourceNameError(options.name);
if (invalidName) { throw new Error(invalidName); }
const template = RESOURCE_TEMPLATES.find((candidate) => candidate.id === options.templateId);
if (!template) { throw new Error('Choose a supported resource template.'); }
const quote = options.quote ?? "'";
const string = (text: string) => `${quote}${text}${quote}`;

const sharedScripts = [
...(template.id !== 'lua' ? ['@ox_lib/init.lua'] : []),
...(template.id === 'qbox' ? ['@qbx_core/modules/lib.lua'] : []),
'shared/config.lua',
];
const manifest = [
"fx_version 'cerulean'",
"game 'gta5'",
`fx_version ${string('cerulean')}`,
`game ${string('gta5')}`,
'',
`name '${options.name}'`,
"version '0.1.0'",
`name ${string(options.name)}`,
`version ${string('0.1.0')}`,
'',
'shared_scripts {',
...sharedScripts.map((script) => ` '${script}',`),
...sharedScripts.map((script) => ` ${string(script)},`),
'}',
'',
"client_script 'client/main.lua'",
"server_script 'server/main.lua'",
`client_script ${string('client/main.lua')}`,
`server_script ${string('server/main.lua')}`,
...(template.dependencies.length ? [
'',
'dependencies {',
...template.dependencies.map((dependency) => ` '${dependency}',`),
...template.dependencies.map((dependency) => ` ${string(dependency)},`),
'}',
] : []),
'',
Expand All @@ -72,6 +80,7 @@ export function createResourcePlan(options: { name: string; templateId: Resource
name: options.name,
templateId: template.id,
templateLabel: template.label,
quote,
dependencies: [...template.dependencies],
files: [
{ path: 'fxmanifest.lua', content: manifest },
Expand Down
7 changes: 5 additions & 2 deletions src/resourceWizard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as vscode from 'vscode';
import type { LuaQuote } from './luaQuote';
import { RESOURCE_TEMPLATES, renderResourcePreview, resourceNameError, type ResourcePlan, type ResourceTemplateId } from './resourceTemplates';
import type { ResourceDestination } from './resourceScaffold';
import { runResourceWizardFlow, type ResourceWizardUi, type WizardDecision } from './resourceWizardFlow';
Expand All @@ -12,7 +13,8 @@ export class ResourceWizard implements vscode.Disposable {
private readonly previews = new Map<string, string>();
private readonly provider: vscode.Disposable;

constructor() {
/** `quote` gives the quote of the strings in a new manifest. */
constructor(private readonly quote: () => Promise<LuaQuote>) {
this.provider = vscode.workspace.registerTextDocumentContentProvider('qbx-resource-preview', {
provideTextDocumentContent: (uri) => this.previews.get(uri.toString()) ?? 'This resource preview has closed.',
});
Expand Down Expand Up @@ -57,7 +59,8 @@ export class ResourceWizard implements vscode.Disposable {
},
};
try {
await runResourceWizardFlow(ui, { initialParent: argument, signal: operation.signal });
// Asked while the prompts are open, so a busy language server does not delay them.
await runResourceWizardFlow(ui, { initialParent: argument, signal: operation.signal, quote: this.quote() });
} catch (error) {
if (!operation.signal.aborted) {
void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error));
Expand Down
10 changes: 7 additions & 3 deletions src/resourceWizardFlow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type * as vscode from 'vscode';
import type { LuaQuote } from './luaQuote';
import { createResourcePlan, type ResourcePlan, type ResourceTemplateId } from './resourceTemplates';
import { createResourceScaffold, validateResourceDestination, type ResourceDestination } from './resourceScaffold';

Expand All @@ -19,10 +20,13 @@ export interface ResourceWizardOperations {
create(parent: vscode.Uri, plan: ResourcePlan, options: { destination: ResourceDestination; signal?: AbortSignal }): Promise<ResourceCreationResult>;
}

/** Prompts only collect a plan; creation is reached exclusively through its matching preview. */
/**
* Prompts only collect a plan; creation is reached exclusively through its matching preview.
* `quote` is the quote of the manifest's strings, awaited once the plan is first needed.
*/
export async function runResourceWizardFlow(
ui: ResourceWizardUi,
options: { initialParent?: vscode.Uri; signal?: AbortSignal } = {},
options: { initialParent?: vscode.Uri; signal?: AbortSignal; quote?: Promise<LuaQuote> } = {},
operations: ResourceWizardOperations = { validate: validateResourceDestination, create: createResourceScaffold },
): Promise<ResourceCreationResult | undefined> {
let parent = options.initialParent;
Expand Down Expand Up @@ -50,7 +54,7 @@ export async function runResourceWizardFlow(
let plan: ResourcePlan;
let destination: ResourceDestination;
try {
plan = createResourcePlan({ name, templateId: template });
plan = createResourcePlan({ name, templateId: template, quote: await options.quote });
destination = await operations.validate(parent, plan.name);
} catch (error) {
if (cancelled()) { return undefined; }
Expand Down
Loading
Loading