Skip to content

Commit a7abb48

Browse files
committed
format code in a separate process
1 parent 4108107 commit a7abb48

4 files changed

Lines changed: 239 additions & 11 deletions

File tree

nattlua/cli/init.lua

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ local config_path = "./" .. DEFAULT_CONFIG_NAME
88
local io = _G.io
99
local type = _G.type
1010
local pairs = _G.pairs
11+
local loadstring = _G.loadstring or _G.load
1112

1213
local function parse_args(args, allowed_options)
1314
local options = {}
1415
local parsed_args = {}
16+
local option_lookup = {}
1517

1618
if allowed_options then
1719
for _, option in ipairs(allowed_options) do
1820
options[option.name] = false
21+
option_lookup[option.name] = option
1922
end
2023
end
2124

@@ -27,7 +30,17 @@ local function parse_args(args, allowed_options)
2730

2831
if exp then
2932
option = option:sub(1, #option - #exp - 1)
30-
val = loadstring("return " .. exp)()
33+
34+
local option_info = option_lookup[option]
35+
36+
if option_info and option_info.arg == "path" then
37+
val = exp
38+
elseif option_info and option_info.arg == "number" then
39+
val = tonumber(exp)
40+
assert(val ~= nil, "invalid number for option " .. option)
41+
else
42+
val = assert(loadstring("return " .. exp))()
43+
end
3144
end
3245

3346
if allowed_options and options[option] == nil then
@@ -185,16 +198,18 @@ config.commands["build"] = {
185198
}
186199
config.commands["fmt"] = {
187200
description = "Format NattLua files",
188-
usage = "nattlua fmt <file|directory> [options]",
201+
usage = "nattlua fmt <file|directory|-> [options]",
189202
options = {
190203
{name = "check", description = "checks if the files are formated"},
204+
{name = "stdin-path", description = "sets the source path when reading code from stdin", arg = "path"},
191205
},
192206
cb = function(args, options, config, cli)
193207
args[1] = args[1] or "./*"
194208

195209
if #args == 1 and args[1] == "-" then
196210
local input = io.read("*all")
197-
io.write(assert(Compiler.New(input, "stdin-", config):Emit()))
211+
local source_path = options["stdin-path"] or "stdin-"
212+
io.write(assert(Compiler.New(input, source_path, config):Emit()))
198213
else
199214
for _, path in ipairs(
200215
cli.get_files{path = args, ignorefiles = config.ignorefiles, ext = {".lua", ".nlua"}}

vscode_extension/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@
7777
},
7878
"description": "arguments for executable"
7979
},
80+
"nattlua.formattingMode": {
81+
"type": "string",
82+
"enum": [
83+
"process",
84+
"lsp",
85+
"auto"
86+
],
87+
"default": "process",
88+
"description": "How document formatting runs. 'process' formats through a short-lived nattlua fmt subprocess, 'lsp' uses the language server, and 'auto' falls back to the language server if the subprocess formatter fails."
89+
},
90+
"nattlua.formatterExecutable": {
91+
"type": "string",
92+
"default": "",
93+
"description": "Optional executable override for formatting. When empty, the formatter reuses nattlua.executable."
94+
},
95+
"nattlua.formatterArguments": {
96+
"type": "array",
97+
"default": [],
98+
"items": {
99+
"type": "string"
100+
},
101+
"description": "Optional base arguments for formatting. When empty, the formatter derives them from nattlua.arguments by replacing the trailing 'lsp' command with 'fmt'."
102+
},
80103
"nattlua.removeUnusedOnSave": {
81104
"type": "boolean",
82105
"default": false,

vscode_extension/src/extension.ts

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
ExtensionContext, languages, Range, TextDocument,
2+
CancellationToken, ExtensionContext, FormattingOptions, languages, Range, TextDocument,
33
TextEdit, window, workspace, DecorationOptions,
44
OutputChannel
55
} from "vscode";
@@ -11,9 +11,9 @@ import {
1111
CloseAction,
1212
ErrorHandler
1313
} from "vscode-languageclient/node";
14-
import { resolveVariables } from "./vscode-variables";
14+
import { resolveVariables, resolveVariablesForDocument } from "./vscode-variables";
1515
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
16-
import { resolve } from "path";
16+
import { basename, resolve } from "path";
1717
import * as fs from "fs";
1818

1919
let client: LanguageClient;
@@ -23,6 +23,14 @@ const MAX_SERVER_RESTARTS = 5;
2323
const SERVER_RESTART_WINDOW_MS = 60_000;
2424
const SERVER_RESTART_DELAY_MS = 1_000;
2525

26+
type FormatterMode = "process" | "lsp" | "auto";
27+
28+
type FormatterInvocation = {
29+
executable: string;
30+
args: string[];
31+
workingDirectory: string;
32+
};
33+
2634
export async function activate(context: ExtensionContext) {
2735
const config = workspace.getConfiguration("nattlua");
2836
let LSPOutput = window.createOutputChannel("NattLua LSP Channel");
@@ -252,6 +260,149 @@ export async function activate(context: ExtensionContext) {
252260
client.sendNotification('nattlua/visibleEditors', { uris });
253261
};
254262

263+
const getDocumentConfig = (document?: TextDocument) => {
264+
return workspace.getConfiguration("nattlua", document?.uri);
265+
};
266+
267+
const getConfiguredExecutable = (document?: TextDocument) => {
268+
const documentConfig = getDocumentConfig(document);
269+
return resolveVariablesForDocument(documentConfig.get<string>("executable") || "nattlua", document);
270+
};
271+
272+
const getConfiguredWorkingDirectory = (document?: TextDocument) => {
273+
const documentConfig = getDocumentConfig(document);
274+
return resolveVariablesForDocument(documentConfig.get<string>("workingDirectory") || "${workspaceFolder}", document);
275+
};
276+
277+
const getConfiguredArguments = (document?: TextDocument) => {
278+
const documentConfig = getDocumentConfig(document);
279+
return (documentConfig.get<string[]>("arguments") || []).map(arg => resolveVariablesForDocument(arg, document));
280+
};
281+
282+
const deriveFormatterInvocation = (document: TextDocument): FormatterInvocation | undefined => {
283+
const documentConfig = getDocumentConfig(document);
284+
const formatterExecutableSetting = documentConfig.get<string>("formatterExecutable") || "";
285+
const formatterArgumentsSetting = documentConfig.get<string[]>("formatterArguments") || [];
286+
const executable = formatterExecutableSetting.trim().length > 0
287+
? resolveVariablesForDocument(formatterExecutableSetting, document)
288+
: getConfiguredExecutable(document);
289+
const baseArgs = formatterArgumentsSetting.length > 0
290+
? formatterArgumentsSetting.map(arg => resolveVariablesForDocument(arg, document))
291+
: getConfiguredArguments(document);
292+
const args = [...baseArgs];
293+
294+
if (formatterArgumentsSetting.length === 0) {
295+
const lspArgIndex = args.lastIndexOf("lsp");
296+
297+
if (lspArgIndex >= 0) {
298+
args[lspArgIndex] = "fmt";
299+
} else if (basename(executable).toLowerCase().includes("nattlua")) {
300+
args.push("fmt");
301+
} else {
302+
return undefined;
303+
}
304+
}
305+
306+
args.push("-");
307+
args.push(`--stdin-path=${JSON.stringify(document.fileName)}`);
308+
309+
return {
310+
executable,
311+
args,
312+
workingDirectory: getConfiguredWorkingDirectory(document),
313+
};
314+
};
315+
316+
const runFormatterProcess = (
317+
invocation: FormatterInvocation,
318+
input: string,
319+
token: CancellationToken,
320+
): Promise<string> => {
321+
return new Promise((resolvePromise, rejectPromise) => {
322+
const formatter = spawn(invocation.executable, invocation.args, {
323+
cwd: invocation.workingDirectory,
324+
shell: true,
325+
});
326+
let stdout = "";
327+
let stderr = "";
328+
let settled = false;
329+
330+
const finish = (callback: () => void) => {
331+
if (settled) {
332+
return;
333+
}
334+
335+
settled = true;
336+
cancelSubscription.dispose();
337+
callback();
338+
};
339+
340+
const cancelSubscription = token.onCancellationRequested(() => {
341+
if (!formatter.killed) {
342+
formatter.kill("SIGTERM");
343+
}
344+
});
345+
346+
formatter.stdout.setEncoding("utf8");
347+
formatter.stdout.on("data", chunk => {
348+
stdout += chunk;
349+
});
350+
351+
formatter.stderr.setEncoding("utf8");
352+
formatter.stderr.on("data", chunk => {
353+
stderr += chunk;
354+
});
355+
356+
formatter.on("error", err => {
357+
finish(() => rejectPromise(err));
358+
});
359+
360+
formatter.on("close", (code, signal) => {
361+
finish(() => {
362+
if (token.isCancellationRequested) {
363+
rejectPromise(new Error("Formatting cancelled"));
364+
return;
365+
}
366+
367+
if (code === 0) {
368+
resolvePromise(stdout);
369+
return;
370+
}
371+
372+
const details = stderr.trim() || stdout.trim() || `Formatter exited with code ${code ?? "null"} signal ${signal ?? "null"}`;
373+
rejectPromise(new Error(details));
374+
});
375+
});
376+
377+
formatter.stdin.on("error", err => {
378+
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
379+
return;
380+
}
381+
382+
finish(() => rejectPromise(err));
383+
});
384+
385+
formatter.stdin.end(input);
386+
});
387+
};
388+
389+
const formatDocumentWithProcess = async (document: TextDocument, token: CancellationToken) => {
390+
const invocation = deriveFormatterInvocation(document);
391+
392+
if (!invocation) {
393+
throw new Error("Could not derive a formatter command from the current NattLua executable and arguments. Configure nattlua.formatterExecutable and nattlua.formatterArguments explicitly.");
394+
}
395+
396+
const original = document.getText();
397+
const formatted = await runFormatterProcess(invocation, original, token);
398+
399+
if (formatted === original) {
400+
return [];
401+
}
402+
403+
return [TextEdit.replace(new Range(document.positionAt(0), document.positionAt(original.length)), formatted)];
404+
};
405+
255406
const executable = resolveVariables(config.get<string>("executable") || "nattlua");
256407
const workingDirectory = resolveVariables(config.get<string>("workingDirectory") || "${workspaceFolder}");
257408
const args = (config.get<string[]>("arguments") || []).map(arg => resolveVariables(arg));
@@ -340,6 +491,37 @@ export async function activate(context: ExtensionContext) {
340491
configurationSection: "nattlua",
341492
},
342493
middleware: {
494+
provideDocumentFormattingEdits: async (
495+
document: TextDocument,
496+
_options: FormattingOptions,
497+
token: CancellationToken,
498+
next,
499+
) => {
500+
const mode = (getDocumentConfig(document).get<string>("formattingMode") || "process") as FormatterMode;
501+
502+
if (mode === "lsp") {
503+
return next(document, _options, token);
504+
}
505+
506+
try {
507+
return await formatDocumentWithProcess(document, token);
508+
} catch (err: any) {
509+
const message = err?.message || String(err);
510+
511+
if (mode === "auto") {
512+
clientOutput.appendLine(`Process formatter failed for ${document.fileName}; falling back to LSP: ${message}`);
513+
return next(document, _options, token);
514+
}
515+
516+
clientOutput.appendLine(`Process formatter failed for ${document.fileName}: ${message}`);
517+
518+
if (!token.isCancellationRequested) {
519+
window.showErrorMessage(`NattLua formatter failed: ${message}`);
520+
}
521+
522+
return [];
523+
}
524+
},
343525
didOpen: (document, next) => {
344526
const visible = isVisibleDocument(document);
345527

vscode_extension/src/vscode-variables.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import * as process from 'process';
33
import * as path from 'path';
44

55
export function resolveVariables(string: string, recursive = false) {
6+
return resolveVariablesForDocument(string, vscode.window.activeTextEditor?.document, recursive);
7+
}
8+
9+
export function resolveVariablesForDocument(string: string, document?: vscode.TextDocument, recursive = false) {
610
const workspaces = vscode.workspace.workspaceFolders || [];
711
const workspace = workspaces.length ? workspaces[0] : null;
812
const activeEditor = vscode.window.activeTextEditor;
9-
const activeDocument = activeEditor?.document;
13+
const editorForDocument = document
14+
? vscode.window.visibleTextEditors.find(editor => editor.document.uri.toString() === document.uri.toString())
15+
: activeEditor;
16+
const activeDocument = document || activeEditor?.document;
1017
const absoluteFilePath = activeDocument?.uri.fsPath || '';
1118
const workspaceFolderPath = workspace?.uri.fsPath || '';
1219
const workspaceFolderBasename = workspace?.name || '';
@@ -35,9 +42,9 @@ export function resolveVariables(string: string, recursive = false) {
3542
? parsedPath.dir.slice(parsedPath.dir.lastIndexOf(path.sep) + 1)
3643
: '';
3744
const cwd = parsedPath.dir || workspaceFolderPath || process.cwd();
38-
const lineNumber = activeEditor ? (activeEditor.selection.start.line + 1).toString() : '1';
39-
const selectedText = activeEditor
40-
? activeEditor.document.getText(new vscode.Range(activeEditor.selection.start, activeEditor.selection.end))
45+
const lineNumber = editorForDocument ? (editorForDocument.selection.start.line + 1).toString() : '1';
46+
const selectedText = editorForDocument
47+
? editorForDocument.document.getText(new vscode.Range(editorForDocument.selection.start, editorForDocument.selection.end))
4148
: '';
4249

4350
string = string.replace(/\${fileWorkspaceFolder}/g, activeWorkspace?.uri.fsPath || workspaceFolderPath);
@@ -59,7 +66,8 @@ export function resolveVariables(string: string, recursive = false) {
5966
});
6067

6168
if (recursive && string.match(/\${(workspaceFolder|workspaceFolderBasename|fileWorkspaceFolder|relativeFile|fileBasename|fileBasenameNoExtension|fileExtname|fileDirname|cwd|pathSeparator|lineNumber|selectedText|env:(.*?)|config:(.*?))}/)) {
62-
string = resolveVariables(string, recursive);
69+
string = resolveVariablesForDocument(string, document, recursive);
6370
}
71+
6472
return string;
6573
}

0 commit comments

Comments
 (0)