mirror of
https://github.com/godotengine/godot-vscode-plugin.git
synced 2026-01-04 10:09:58 +03:00
346 lines
9.5 KiB
TypeScript
346 lines
9.5 KiB
TypeScript
import { TextEdit } from "vscode";
|
|
import type { TextDocument, TextLine } from "vscode";
|
|
import * as fs from "node:fs";
|
|
import * as vsctm from "vscode-textmate";
|
|
import * as oniguruma from "vscode-oniguruma";
|
|
import { keywords, symbols } from "./symbols";
|
|
import { get_configuration, get_extension_uri, createLogger, is_debug_mode } from "../utils";
|
|
|
|
const log = createLogger("formatter.tm");
|
|
|
|
// Promisify readFile
|
|
function readFile(path) {
|
|
return new Promise((resolve, reject) => {
|
|
fs.readFile(path, (error, data) => (error ? reject(error) : resolve(data)));
|
|
});
|
|
}
|
|
|
|
const grammarPath = get_extension_uri("syntaxes/GDScript.tmLanguage.json").fsPath;
|
|
const wasmPath = get_extension_uri("resources/onig.wasm").fsPath;
|
|
const wasmBin = fs.readFileSync(wasmPath).buffer;
|
|
|
|
// Create a registry that can create a grammar from a scope name.
|
|
const registry = new vsctm.Registry({
|
|
onigLib: oniguruma.loadWASM(wasmBin).then(() => {
|
|
return {
|
|
createOnigScanner(patterns) {
|
|
return new oniguruma.OnigScanner(patterns);
|
|
},
|
|
createOnigString(s) {
|
|
return new oniguruma.OnigString(s);
|
|
},
|
|
};
|
|
}),
|
|
loadGrammar: (scopeName) => {
|
|
if (scopeName === "source.gdscript") {
|
|
return readFile(grammarPath).then((data) => vsctm.parseRawGrammar(data.toString(), grammarPath));
|
|
}
|
|
// console.log(`Unknown scope name: ${scopeName}`);
|
|
return null;
|
|
},
|
|
});
|
|
|
|
interface Token {
|
|
// startIndex: number;
|
|
// endIndex: number;
|
|
scopes: string[];
|
|
original: string;
|
|
value: string;
|
|
type?: string;
|
|
param?: boolean;
|
|
string?: boolean;
|
|
skip?: boolean;
|
|
identifier?: boolean;
|
|
}
|
|
|
|
export interface FormatterOptions {
|
|
maxEmptyLines: 0 | 1 | 2;
|
|
denseFunctionParameters: boolean;
|
|
spacesBeforeEndOfLineComment: 1 | 2;
|
|
}
|
|
|
|
function get_formatter_options() {
|
|
const options: FormatterOptions = {
|
|
maxEmptyLines: get_configuration("formatter.maxEmptyLines") === "1" ? 1 : 2,
|
|
denseFunctionParameters: get_configuration("formatter.denseFunctionParameters"),
|
|
spacesBeforeEndOfLineComment: get_configuration("formatter.spacesBeforeEndOfLineComment") === "1" ? 1 : 2,
|
|
};
|
|
|
|
return options;
|
|
}
|
|
|
|
function parse_token(token: Token) {
|
|
if (token.scopes.includes("string.quoted.gdscript")) {
|
|
token.string = true;
|
|
}
|
|
if (token.scopes.includes("meta.function.parameters.gdscript")) {
|
|
token.param = true;
|
|
}
|
|
if (token.scopes[0].includes("constant.numeric")) {
|
|
token.type = "literal";
|
|
return;
|
|
}
|
|
if (token.value.match(/[A-Za-z_]\w+/)) {
|
|
token.identifier = true;
|
|
}
|
|
if (token.scopes.includes("meta.literal.nodepath.gdscript")) {
|
|
token.skip = true;
|
|
token.type = "nodepath";
|
|
return;
|
|
}
|
|
if (token.scopes.includes("meta.literal.nodepath.bare.gdscript")) {
|
|
token.skip = true;
|
|
token.type = "bare_nodepath";
|
|
return;
|
|
}
|
|
if (token.scopes.includes("keyword.control.flow.gdscript")) {
|
|
token.type = "keyword";
|
|
return;
|
|
}
|
|
if (keywords.includes(token.value)) {
|
|
token.type = "keyword";
|
|
return;
|
|
}
|
|
if (symbols.includes(token.value)) {
|
|
token.type = "symbol";
|
|
return;
|
|
}
|
|
// "preload" is highlighted as a keyword but it behaves like a function
|
|
if (token.value === "preload") {
|
|
return;
|
|
}
|
|
if (token.scopes.includes("keyword.language.gdscript")) {
|
|
token.type = "keyword";
|
|
return;
|
|
}
|
|
if (token.scopes.includes("constant.language.gdscript")) {
|
|
token.type = "constant";
|
|
return;
|
|
}
|
|
if (token.scopes.includes("variable.other.gdscript")) {
|
|
token.type = "variable";
|
|
return;
|
|
}
|
|
if (token.scopes.includes("comment.line.number-sign.gdscript")) {
|
|
token.type = "comment";
|
|
return;
|
|
}
|
|
}
|
|
|
|
function between(tokens: Token[], current: number, options: FormatterOptions) {
|
|
const nextToken = tokens[current];
|
|
const prevToken = tokens[current - 1];
|
|
const next = nextToken.value;
|
|
const prev = prevToken?.value;
|
|
|
|
// console.log(prevToken, nextToken);
|
|
|
|
if (!prev) return "";
|
|
|
|
if (next === "##") return options.spacesBeforeEndOfLineComment === 2 ? " " : " ";
|
|
if (next === "#") return options.spacesBeforeEndOfLineComment === 2 ? " " : " ";
|
|
if (prevToken.skip && nextToken.skip) return "";
|
|
|
|
if (prev === "(") return "";
|
|
if (prev === ".") {
|
|
if (nextToken?.type === "symbol") return " ";
|
|
return "";
|
|
}
|
|
if (next === ".") return "";
|
|
|
|
if (nextToken.param) {
|
|
if (options.denseFunctionParameters) {
|
|
if (prev === "-" || prev === "+") {
|
|
if (tokens[current - 2]?.value === "=") return "";
|
|
if (["keyword", "symbol"].includes(tokens[current - 2]?.type)) {
|
|
return "";
|
|
}
|
|
if ([",", "("].includes(tokens[current - 2]?.value)) {
|
|
return "";
|
|
}
|
|
}
|
|
if (next === "%") return " ";
|
|
if (prev === "%") return " ";
|
|
if (next === "=") {
|
|
if (tokens[current - 2]?.value === ":") return " ";
|
|
return "";
|
|
}
|
|
if (prev === "=") {
|
|
if (tokens[current - 3]?.value === ":") return " ";
|
|
return "";
|
|
}
|
|
if (prevToken?.type === "symbol") return " ";
|
|
if (nextToken.type === "symbol") return " ";
|
|
} else {
|
|
if (next === ":") {
|
|
if (tokens[current + 1]?.value === "=") return " ";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (next === ":") {
|
|
if (["var", "const"].includes(tokens[current - 2]?.value)) {
|
|
if (tokens[current + 1]?.value !== "=") return "";
|
|
return " ";
|
|
}
|
|
if (prevToken?.type === "keyword") return "";
|
|
}
|
|
if (prev === "@") return "";
|
|
|
|
if (prev === "-" || prev === "+") {
|
|
if (next === "(") return " ";
|
|
if (["keyword", "symbol"].includes(tokens[current - 2]?.type)) {
|
|
return "";
|
|
}
|
|
if ([",", "(", "["].includes(tokens[current - 2]?.value)) {
|
|
return "";
|
|
}
|
|
if (nextToken.identifier) return " ";
|
|
if (current === 1) return "";
|
|
}
|
|
|
|
if (prev === ":" && next === "=") return "";
|
|
if (next === "(") {
|
|
if (prev === "export") return "";
|
|
if (prev === "func") return "";
|
|
if (prev === "assert") return "";
|
|
}
|
|
|
|
if (prev === ")" && nextToken.type === "keyword") return " ";
|
|
|
|
if (prev === "[" && nextToken.type === "symbol") return "";
|
|
if (prev === "[" && nextToken.type === "nodepath") return "";
|
|
if (prev === "[" && nextToken.type === "bare_nodepath") return "";
|
|
if (prev === ":") return " ";
|
|
if (prev === ";") return " ";
|
|
if (prev === "##") return " ";
|
|
if (prev === "#") return " ";
|
|
if (next === "=") return " ";
|
|
if (prev === "=") return " ";
|
|
if (tokens[current - 2]?.value === "=") {
|
|
if (["+", "-"].includes(prev)) return "";
|
|
}
|
|
if (prev === "(") return "";
|
|
if (next === "{") return " ";
|
|
if (next === "\\") return " ";
|
|
if (next === "{}") return " ";
|
|
|
|
if (prevToken?.type === "keyword") return " ";
|
|
if (nextToken.type === "keyword") return " ";
|
|
if (prevToken?.type === "symbol") return " ";
|
|
if (nextToken.type === "symbol") return " ";
|
|
|
|
if (prev === ",") return " ";
|
|
|
|
return "";
|
|
}
|
|
|
|
let grammar = null;
|
|
|
|
registry.loadGrammar("source.gdscript").then((g) => {
|
|
grammar = g;
|
|
});
|
|
|
|
function is_comment(line: TextLine): boolean {
|
|
return line.text[line.firstNonWhitespaceCharacterIndex] === "#";
|
|
}
|
|
|
|
export function format_document(document: TextDocument, _options?: FormatterOptions): TextEdit[] {
|
|
// quit early if grammar is not loaded
|
|
if (!grammar) {
|
|
return [];
|
|
}
|
|
const edits: TextEdit[] = [];
|
|
|
|
const options = _options ?? get_formatter_options();
|
|
|
|
let lastToken = null;
|
|
let lineTokens: vsctm.ITokenizeLineResult = null;
|
|
let onlyEmptyLinesSoFar = true;
|
|
let emptyLineCount = 0;
|
|
for (let lineNum = 0; lineNum < document.lineCount; lineNum++) {
|
|
const line = document.lineAt(lineNum);
|
|
|
|
// skip empty lines
|
|
if (line.isEmptyOrWhitespace) {
|
|
// delete empty lines at the beginning of the file
|
|
if (onlyEmptyLinesSoFar) {
|
|
edits.push(TextEdit.delete(line.rangeIncludingLineBreak));
|
|
} else {
|
|
emptyLineCount++;
|
|
}
|
|
|
|
// delete empty lines at the end of the file
|
|
if (lineNum === document.lineCount - 1) {
|
|
for (let i = lineNum - emptyLineCount + 1; i < document.lineCount; i++) {
|
|
edits.push(TextEdit.delete(document.lineAt(i).rangeIncludingLineBreak));
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
onlyEmptyLinesSoFar = false;
|
|
|
|
// delete consecutive empty lines
|
|
if (emptyLineCount) {
|
|
let maxEmptyLines = options.maxEmptyLines;
|
|
if (lastToken === ":") {
|
|
maxEmptyLines = 0;
|
|
}
|
|
for (let i = emptyLineCount - maxEmptyLines; i > 0; i--) {
|
|
edits.push(TextEdit.delete(document.lineAt(lineNum - i).rangeIncludingLineBreak));
|
|
}
|
|
emptyLineCount = 0;
|
|
}
|
|
|
|
// skip comments
|
|
if (is_comment(line)) {
|
|
continue;
|
|
}
|
|
|
|
let nextLine = "";
|
|
lineTokens = grammar.tokenizeLine(line.text, lineTokens?.ruleStack ?? vsctm.INITIAL);
|
|
|
|
// TODO: detect whitespace type and automatically convert
|
|
const leadingWhitespace = line.text.slice(0, line.firstNonWhitespaceCharacterIndex);
|
|
nextLine += leadingWhitespace;
|
|
const first = lineTokens.tokens[0];
|
|
if (line.text.slice(first.startIndex, first.endIndex).trim() === "") {
|
|
lineTokens.tokens.shift();
|
|
}
|
|
|
|
const tokens: Token[] = [];
|
|
for (const t of lineTokens.tokens) {
|
|
const token: Token = {
|
|
scopes: [t.scopes.join(" "), ...t.scopes],
|
|
original: line.text.slice(t.startIndex, t.endIndex),
|
|
value: line.text.slice(t.startIndex, t.endIndex).trim(),
|
|
};
|
|
parse_token(token);
|
|
// skip whitespace tokens
|
|
if (!token.string && token.value.trim() === "") {
|
|
continue;
|
|
}
|
|
tokens.push(token);
|
|
}
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
if (is_debug_mode()) log.debug(i, tokens[i].value, tokens[i]);
|
|
|
|
if (i === 0 && tokens[i].string) {
|
|
// leading whitespace is already accounted for
|
|
nextLine += tokens[i].original.trimStart();
|
|
} else if (i > 0 && tokens[i - 1].string && tokens[i].string) {
|
|
nextLine += tokens[i].original;
|
|
} else {
|
|
nextLine += between(tokens, i, options) + tokens[i].value.trim();
|
|
}
|
|
if (tokens[i].type !== "comment") {
|
|
lastToken = tokens[i].value;
|
|
}
|
|
}
|
|
|
|
edits.push(TextEdit.replace(line.range, nextLine));
|
|
}
|
|
|
|
return edits;
|
|
}
|