diff --git a/README.md b/README.md index 3d60135..75cb25e 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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). @@ -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. | diff --git a/docs/resource-templates.md b/docs/resource-templates.md index 106097b..7b8c774 100644 --- a/docs/resource-templates.md +++ b/docs/resource-templates.md @@ -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 | | --- | --- | --- | diff --git a/package.json b/package.json index 35cf125..a8b41f3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -351,6 +351,13 @@ "injectTo": [ "source.lua" ] + }, + { + "scopeName": "luacats.documentation.injection", + "path": "./syntaxes/luacats-documentation.injection.json", + "injectTo": [ + "source.lua" + ] } ], "jsonValidation": [ @@ -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": [ diff --git a/src/extension.ts b/src/extension.ts index 0b2dc32..5630b14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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'; @@ -45,7 +47,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { wizardDisposed = true; resourceWizard?.dispose(); } }); @@ -97,7 +99,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { 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) => { @@ -141,6 +143,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { await client?.stop(); client = undefined; @@ -206,6 +225,19 @@ async function start(context: vscode.ExtensionContext): Promise { }; 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; + capabilities.experimental = { ...experimental, commands: { commands: ['editor.action.triggerSuggest'] } }; + }, + initialize() {}, + getState() { + return { kind: 'static' }; + }, + clear() {}, + }); client.onDidChangeState((event) => { if (event.newState === State.Running) { void refreshStatus(); diff --git a/src/luaQuote.ts b/src/luaQuote.ts new file mode 100644 index 0000000..dd0f5ee --- /dev/null +++ b/src/luaQuote.ts @@ -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 { + try { + const quote = await request('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("'", '"'); +} diff --git a/src/manifestSnippets.ts b/src/manifestSnippets.ts new file mode 100644 index 0000000..49a0598 --- /dev/null +++ b/src/manifestSnippets.ts @@ -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 { + 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 | 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; + })); + }, + }); +} diff --git a/src/resourceScaffold.ts b/src/resourceScaffold.ts index 94b517c..557f054 100644 --- a/src/resourceScaffold.ts +++ b/src/resourceScaffold.ts @@ -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.'); } diff --git a/src/resourceTemplates.ts b/src/resourceTemplates.ts index cc8f506..83882fe 100644 --- a/src/resourceTemplates.ts +++ b/src/resourceTemplates.ts @@ -1,3 +1,5 @@ +import type { LuaQuote } from './luaQuote'; + export type ResourceTemplateId = 'lua' | 'ox_lib' | 'qbox'; export interface ResourceTemplate { @@ -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 }[]; } @@ -32,12 +35,17 @@ 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'] : []), @@ -45,22 +53,22 @@ export function createResourcePlan(options: { name: string; templateId: Resource '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)},`), '}', ] : []), '', @@ -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 }, diff --git a/src/resourceWizard.ts b/src/resourceWizard.ts index d17f02a..d26966a 100644 --- a/src/resourceWizard.ts +++ b/src/resourceWizard.ts @@ -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'; @@ -12,7 +13,8 @@ export class ResourceWizard implements vscode.Disposable { private readonly previews = new Map(); private readonly provider: vscode.Disposable; - constructor() { + /** `quote` gives the quote of the strings in a new manifest. */ + constructor(private readonly quote: () => Promise) { this.provider = vscode.workspace.registerTextDocumentContentProvider('qbx-resource-preview', { provideTextDocumentContent: (uri) => this.previews.get(uri.toString()) ?? 'This resource preview has closed.', }); @@ -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)); diff --git a/src/resourceWizardFlow.ts b/src/resourceWizardFlow.ts index 8e58578..08f6da6 100644 --- a/src/resourceWizardFlow.ts +++ b/src/resourceWizardFlow.ts @@ -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'; @@ -19,10 +20,13 @@ export interface ResourceWizardOperations { create(parent: vscode.Uri, plan: ResourcePlan, options: { destination: ResourceDestination; signal?: AbortSignal }): Promise; } -/** 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 } = {}, operations: ResourceWizardOperations = { validate: validateResourceDestination, create: createResourceScaffold }, ): Promise { let parent = options.initialParent; @@ -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; } diff --git a/src/snippetBrowser.ts b/src/snippetBrowser.ts index b7ae1c4..a3d96a9 100644 --- a/src/snippetBrowser.ts +++ b/src/snippetBrowser.ts @@ -1,11 +1,13 @@ import { randomBytes } from 'node:crypto'; import * as path from 'node:path'; import * as vscode from 'vscode'; +import { luaQuote, withQuote } from './luaQuote'; +import { loadManifestSnippets } from './manifestSnippets'; import { LuaEditorTargetTracker, type LuaInsertionTarget } from './referenceBrowser'; import type { ReferenceRequest } from './referenceTypes'; import { getSnippetHtml } from './snippetHtml'; import { SnippetSession } from './snippetSession'; -import { parseSnippetFile, snippetNameError, SnippetStore, type SnippetSource } from './snippetStore'; +import { snippetNameError, SnippetStore, type SnippetSource } from './snippetStore'; import type { RecipeSnippet, SnippetCatalog, SnippetWebviewMessage } from './snippetTypes'; type ManageAction = Extract['action']; @@ -135,8 +137,9 @@ export class SnippetBrowser implements vscode.Disposable { async loadCatalog(): Promise { const target = this.tracker.capture(); const params = target?.document.uri.scheme === 'file' ? { uri: target.document.uri.toString() } : null; - const results = await Promise.allSettled([ - this.request('qbx/snippets', params), this.store.load(), this.manifestSnippets(), + const [results, quote] = await Promise.all([ + Promise.allSettled([this.request('qbx/snippets', params), this.store.load(), this.manifestSnippets()]), + luaQuote(this.request, target?.document.uri), ]); const items: RecipeSnippet[] = []; const issues: string[] = []; @@ -148,7 +151,7 @@ export class SnippetBrowser implements vscode.Disposable { issues.push(server.status === 'rejected' ? `Lua recipes: ${this.errorText(server.reason)}` : 'Lua recipes returned invalid data. Restart the language server and refresh.'); } - if (manifest.status === 'fulfilled') { items.push(...manifest.value); } + if (manifest.status === 'fulfilled') { items.push(...manifest.value.map((item) => ({ ...item, body: withQuote(item.body, quote) }))); } else { issues.push(`Manifest recipes: ${this.errorText(manifest.reason)}`); } if (custom.status === 'fulfilled') { // Source IDs stay in the extension host. Webview actions only submit the opaque snippet ID. @@ -159,15 +162,11 @@ export class SnippetBrowser implements vscode.Disposable { } private manifestSnippets(): Promise { - this.manifests ??= Promise.resolve(vscode.workspace.fs.readFile(vscode.Uri.joinPath(this.extensionUri, 'snippets', 'fxmanifest.json'))).then((bytes) => { - const parsed = parseSnippetFile(Buffer.from(bytes).toString('utf8')); - if (parsed.issues.length) { throw new Error(parsed.issues.join(' ')); } - return parsed.snippets.map((item) => ({ - id: `builtin:manifest:${encodeURIComponent(item.name)}`, label: item.name, - description: item.description, body: item.body, prefix: item.prefix, - source: 'builtin' as const, sourceLabel: 'Built-in · Resource manifests', - })); - }); + this.manifests ??= loadManifestSnippets(this.extensionUri).then((snippets) => snippets.map((item) => ({ + id: `builtin:manifest:${encodeURIComponent(item.name)}`, label: item.name, + description: item.description, body: item.body, prefix: item.prefix, + source: 'builtin' as const, sourceLabel: 'Built-in · Resource manifests', + }))); return this.manifests; } diff --git a/syntaxes/luacats-documentation.injection.json b/syntaxes/luacats-documentation.injection.json new file mode 100644 index 0000000..83ca51b --- /dev/null +++ b/syntaxes/luacats-documentation.injection.json @@ -0,0 +1,31 @@ +{ + "scopeName": "luacats.documentation.injection", + "injectionSelector": "L:comment.line.double-dash.documentation.lua", + "patterns": [ + { "include": "#callback" }, + { "include": "#field-visibility" }, + { "include": "#attributes" } + ], + "repository": { + "callback": { + "match": "(?<=---)[ \\t]*(@callback)[ \\t]+(register|await|trigger)\\b(?:[ \\t]+([A-Za-z_][\\w.\\-]*))?", + "captures": { + "1": { "name": "storage.type.annotation.luacats" }, + "2": { "name": "storage.modifier.luacats" }, + "3": { "name": "entity.name.namespace.luacats" } + } + }, + "field-visibility": { + "match": "(?<=@field )(public|private|protected|package)\\b", + "name": "storage.modifier.luacats" + }, + "attributes": { + "match": "(?<=@class |@alias |@enum |@field |@overload |public |private |protected |package )(\\()[ \\t]*([A-Za-z]+(?:[ \\t]*,[ \\t]*[A-Za-z]+)*)[ \\t]*(\\))", + "captures": { + "1": { "name": "punctuation.definition.attribute.begin.luacats" }, + "2": { "name": "storage.modifier.attribute.luacats" }, + "3": { "name": "punctuation.definition.attribute.end.luacats" } + } + } + } +} diff --git a/test/suite/index.ts b/test/suite/index.ts index 01025a7..965f40b 100644 --- a/test/suite/index.ts +++ b/test/suite/index.ts @@ -200,6 +200,33 @@ const tests: Test[] = [ } }, ], + [ + 'Enter continues LuaCATS annotation lines and clears an empty one', + async () => { + const document = await vscode.workspace.openTextDocument(workspaceFile('myresource/client/main.lua')); + const editor = await vscode.window.showTextDocument(document); + const original = document.getText(); + const cursor = () => `${editor.selection.active.line}:${editor.selection.active.character}`; + try { + await editor.edit((edit) => edit.insert(document.positionAt(original.length), '\n ---@param event string')); + const line = document.lineCount - 1; + const end = document.lineAt(line).range.end; + editor.selection = new vscode.Selection(end, end); + await vscode.commands.executeCommand('type', { text: '\n' }); + await waitFor('the ---@ continuation', () => (document.lineAt(line + 1).text === ' ---@' ? true : undefined)); + assert.ok(editor.selection.active.isEqual(new vscode.Position(line + 1, 8)), `cursor after ---@, at ${cursor()}`); + + await vscode.commands.executeCommand('type', { text: '\n' }); + await waitFor('the bare ---@ to clear', () => + (document.lineCount === line + 2 && document.lineAt(line + 1).text.trim() === '' ? true : undefined)); + assert.equal(editor.selection.active.line, line + 1, `cursor stays on the cleared line, at ${cursor()}`); + } finally { + const whole = new vscode.Range(new vscode.Position(0, 0), document.positionAt(document.getText().length)); + await editor.edit((edit) => edit.replace(whole, original)); + await document.save(); + } + }, + ], [ 'framework callbacks expose local payloads and navigate to their own handlers', async () => { @@ -273,6 +300,103 @@ CallbackESX.TriggerServerCallback('editor:lookup', function(result) end, 42) } }, ], + [ + 'typing a letter after ---@ lists the annotation tags', + async () => { + assert.equal(vscode.workspace.getConfiguration('editor', { languageId: 'lua' }).get('suggest.preview'), true); + const document = await vscode.workspace.openTextDocument(workspaceFile('myresource/client/main.lua')); + const editor = await vscode.window.showTextDocument(document); + const original = document.getText(); + try { + await editor.edit((edit) => edit.insert(document.positionAt(original.length), '\n---@param event string')); + const line = document.lineCount - 1; + const end = document.lineAt(line).range.end; + editor.selection = new vscode.Selection(end, end); + await vscode.commands.executeCommand('type', { text: '\n' }); + await waitFor('the ---@ continuation', () => (document.lineAt(line + 1).text === '---@' ? true : undefined)); + await vscode.commands.executeCommand('type', { text: 'r' }); + await waitFor('the return tag to be accepted', async () => { + await vscode.commands.executeCommand('acceptSelectedSuggestion'); + return document.lineAt(line + 1).text.startsWith('---@return ') ? true : undefined; + }); + } finally { + await vscode.commands.executeCommand('hideSuggestWidget'); + const whole = new vscode.Range(new vscode.Position(0, 0), document.positionAt(document.getText().length)); + await editor.edit((edit) => edit.replace(whole, original)); + await document.save(); + } + }, + ], + [ + 'annotation highlighting colors callback roles and side attributes', + async () => { + const fs = await import('node:fs/promises'); + const os = await import('node:os'); + const file = vscode.Uri.file(path.join(os.tmpdir(), `qbx-luacats-highlight-${process.pid}.lua`)); + await fs.writeFile(file.fsPath, [ + '---@callback register shop', + '---@class (exact, server) Account', + '---@field private (client) hud table', + '---@overload (client) fun(id: integer)', + ].join('\n')); + try { + const tokens = await vscode.commands.executeCommand<{ c: string; t: string }[]>('_workbench.captureSyntaxTokens', file); + const scopes = (text: string) => tokens.find((token) => token.c.trim() === text)?.t ?? ''; + // VS Code's own Lua grammar colors the rest of each line, as before. + for (const [text, scope] of [ + ['@callback', 'storage.type.annotation.luacats'], + ['register', 'storage.modifier.luacats'], + ['shop', 'entity.name.namespace.luacats'], + ['exact, server', 'storage.modifier.attribute.luacats'], + ['Account', 'support.class.lua'], + ['private', 'storage.modifier.luacats'], + ['client', 'storage.modifier.attribute.luacats'], + ['hud', 'entity.name.variable.lua'], + ['integer', 'support.type.lua'], + ]) { + const seen = tokens.map((token) => `${JSON.stringify(token.c)} ${token.t}`).join('\n'); + assert.ok(scopes(text).split(' ').includes(scope), `${text}: expected ${scope} in "${scopes(text)}"\n${seen}`); + } + } finally { + await fs.rm(file.fsPath, { force: true }); + } + }, + ], + [ + 'callback wrapper call snippets reopen suggestions in the name', + async () => { + const shared = await vscode.workspace.openTextDocument(workspaceFile('myresource/shared/config.lua')); + const client = await vscode.workspace.openTextDocument(workspaceFile('myresource/client/main.lua')); + const originals = [shared, client].map((document) => ({ document, text: document.getText() })); + const edit = new vscode.WorkspaceEdit(); + edit.insert(shared.uri, shared.positionAt(shared.getText().length), ` +---@callback await +---@param name string +---@param ... any +function AwaitEditorProbe(name, ...) end +`); + edit.insert(client.uri, client.positionAt(client.getText().length), '\nAwaitEditorPro'); + try { + assert.ok(await vscode.workspace.applyEdit(edit)); + const position = client.positionAt(client.getText().length); + const snippet = await waitFor('the wrapper call snippet', async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', client.uri, position, + ); + return result?.items.find((item) => item.insertText instanceof vscode.SnippetString + && item.insertText.value === "AwaitEditorProbe('$1'$2)"); + }); + assert.equal(snippet.command?.command, 'editor.action.triggerSuggest'); + } finally { + const restore = new vscode.WorkspaceEdit(); + for (const { document, text } of originals) { + restore.replace(document.uri, new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), text); + } + assert.ok(await vscode.workspace.applyEdit(restore)); + await Promise.all(originals.map(({ document }) => document.save())); + } + }, + ], [ 'snippets are offered while typing a statement', async () => { @@ -299,6 +423,33 @@ CallbackESX.TriggerServerCallback('editor:lookup', function(result) end, 42) } }, ], + [ + 'manifest snippets write strings in the quote of the document', + async () => { + const document = await vscode.workspace.openTextDocument(workspaceFile('myresource/client/main.lua')); + const editor = await vscode.window.showTextDocument(document); + const original = document.getText(); + const manifestSnippet = async (text: string): Promise => { + const whole = new vscode.Range(new vscode.Position(0, 0), document.positionAt(document.getText().length)); + await editor.edit((edit) => edit.replace(whole, text)); + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', document.uri, document.positionAt(text.length), + ); + const item = list.items.find((item) => item.kind === vscode.CompletionItemKind.Snippet + && (typeof item.label === 'string' ? item.label : item.label.label) === 'fxmanifest'); + assert.ok(item?.insertText instanceof vscode.SnippetString, `no fxmanifest snippet in ${list.items.length} items`); + return item.insertText.value; + }; + try { + assert.match(await manifestSnippet("local label = 'x'\nfxmanif"), /^fx_version 'cerulean'$/m); + assert.match(await manifestSnippet('local label = "x"\nfxmanif'), /^fx_version "cerulean"$/m); + } finally { + const whole = new vscode.Range(new vscode.Position(0, 0), document.positionAt(document.getText().length)); + await editor.edit((edit) => edit.replace(whole, original)); + await document.save(); + } + }, + ], [ 'diagnostics come from qbx-lint with manifest context', async () => { diff --git a/test/suite/resourceWizard.ts b/test/suite/resourceWizard.ts index a8c94a6..ed0fab8 100644 --- a/test/suite/resourceWizard.ts +++ b/test/suite/resourceWizard.ts @@ -100,6 +100,23 @@ const tests: Test[] = [ } } }], + ['resource manifests write their strings in the quote the wizard is given', async () => withTemporaryRoot(async (root) => { + const manifest = (plan: ResourcePlan) => plan.files.find((file) => file.path === 'fxmanifest.lua')!.content; + const single = createResourcePlan({ name: 'example', templateId: 'qbox' }); + const double = createResourcePlan({ name: 'example', templateId: 'qbox', quote: '"' }); + assert.equal(single.quote, "'"); + assert.match(manifest(double), /^fx_version "cerulean"$/m); + assert.equal(manifest(double), manifest(single).replaceAll("'", '"')); + assert.deepEqual(double.files.slice(1), single.files.slice(1), 'only the manifest has strings'); + + const h = wizardHarness(); + await runResourceWizardFlow(h.ui, { quote: Promise.resolve('"') }, h.operations); + assert.equal(h.creations[0].plan.quote, '"'); + const scaffolder = new ResourceScaffolder(); + const destination = await scaffolder.validateResourceDestination(root, double.name); + const created = await scaffolder.createResourceScaffold(root, double, { destination }); + assert.equal(await fs.readFile(created.manifest.fsPath, 'utf8'), manifest(double), 'the reviewed quote is the one written'); + })], ['resource previews contain exact file contents and keep hostile destination text inert', async () => { const { marked } = await import('marked'); const plan = createResourcePlan({ name: 'example', templateId: 'qbox' }); diff --git a/test/suite/snippets.ts b/test/suite/snippets.ts index 7219f6c..f5b7209 100644 --- a/test/suite/snippets.ts +++ b/test/suite/snippets.ts @@ -100,7 +100,9 @@ async function withStore(body: (fixture: { store: SnippetStore; root: string; gl } assert.ok(path.resolve(root).startsWith(`${path.resolve(os.tmpdir())}${path.sep}`)); assert.ok(path.basename(root).startsWith('qbx-snippet-tests-')); - await fs.rm(root, { recursive: true, force: true }); + // Showing an editor makes the built-in Git extension run `git rev-parse` in the file's folder; + // Windows cannot remove a process's working directory until it exits, so retry EBUSY briefly. + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } } @@ -266,15 +268,26 @@ const tests: Test[] = [ }); try { const catalog = await browser.loadCatalog(); - assert.deepEqual(requested, ['qbx/snippets']); + assert.deepEqual(requested, ['qbx/snippets', 'qbx/quote']); assert.equal(catalog.items.find((item) => item.source === 'personal' && item.label === 'Offline personal')?.body, 'print("${1:offline}")$0'); const manifests = catalog.items.filter((item) => item.id.startsWith('builtin:manifest:')); assert.equal(manifests.length, 2, 'manifest recipes must not depend on a running language server'); - assert.ok(manifests.some((item) => item.body.includes("fx_version 'cerulean'"))); + assert.ok(manifests.some((item) => item.body.includes("fx_version 'cerulean'")), 'an offline server leaves the default quote'); assert.ok(catalog.issues.some((issue) => issue.includes('Test language server is offline'))); assert.equal(await fs.readFile(personalSource.uri.fsPath, 'utf8'), original, 'browsing must not rewrite the library'); } finally { browser.dispose(); } })], + ['manifest recipes take the quote the language server picks', async () => withStore(async ({ global }) => { + const extension = vscode.extensions.getExtension('qbox.qbx-lua'); + assert.ok(extension); + const browser = new SnippetBrowser(extension.extensionUri, global, async (method: string): Promise => + (method === 'qbx/quote' ? '"' : []) as T); + try { + const manifests = (await browser.loadCatalog()).items.filter((item) => item.id.startsWith('builtin:manifest:')); + assert.ok(manifests.some((item) => item.body.includes('fx_version "cerulean"')), manifests.map((item) => item.body).join('\n')); + assert.ok(manifests.every((item) => !item.body.includes("'"))); + } finally { browser.dispose(); } + })], ['snippet library changes notify the browser and saved edits become available', async () => withStore(async ({ store }) => { const source = store.sources()[0]; let changes = 0;