дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(languages): add Windows batch file support (#294)
.bat and .cmd files were plain text: no highlighting, no comment toggling and no Run. Batch is now a built-in language (languages/bat.toml, line comment @REM as in VS Code) with a CodeMirror stream mode for keywords, labels, %VAR% / %%i / !VAR! variables, strings and :: / REM comments. Comment toggling always separates a word-like token (REM) from the code. On Windows hosts the editor and project-view Run use cmd.exe /c <file> in the file's directory; other hosts don't offer it. 未在 Windows 实机验证运行。 Fixes #236
Цей коміт міститься в:
1 джерело
1602e4b530
коміт
285c7187fb
12 files changed
+213
-14
No files matched your search
@@ -8,6 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
("c", include_str!("../../../languages/c.toml")),
|
||||
("clojure", include_str!("../../../languages/clojure.toml")),
|
||||
("cmake", include_str!("../../../languages/cmake.toml")),
|
||||
@@ -348,8 +349,10 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// `name`s exported by `@codemirror/language-data` (the UI resolves `ui-mode` against these).
|
||||
/// `name`s exported by `@codemirror/language-data` or built into `src/editor/core/cm/languages.ts`
|
||||
/// (the UI resolves `ui-mode` against these).
|
||||
const UI_MODES: &[&str] = &[
|
||||
"Batch",
|
||||
"C",
|
||||
"C++",
|
||||
"CQL",
|
||||
@@ -511,6 +514,7 @@ mod tests {
|
||||
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
|
||||
assert_eq!(detect(®, "README"), "plaintext");
|
||||
assert_eq!(detect(®, "unknown.zzz"), "plaintext");
|
||||
assert_eq!(reg.get(&LanguageId::new("bat")).unwrap().line_comment.as_deref(), Some("@REM"));
|
||||
let rust = reg.get(&LanguageId::new("rust")).unwrap();
|
||||
assert_eq!(rust.language_servers[0].name, "rust-analyzer");
|
||||
}
|
||||
@@ -679,6 +683,8 @@ mod tests {
|
||||
("deploy.sh", "shellscript"),
|
||||
(".zshrc", "shellscript"),
|
||||
("Build.ps1", "powershell"),
|
||||
("build.bat", "bat"),
|
||||
("run.cmd", "bat"),
|
||||
("schema.sql", "sql"),
|
||||
("api.http", "http"),
|
||||
("api.rest", "http"),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
id = "bat"
|
||||
name = "Batch"
|
||||
extensions = ["bat", "cmd"]
|
||||
line-comment = "@REM"
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Batch"
|
||||
@@ -115,6 +115,8 @@ const EXT_LANG: Record<string, string> = {
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
sh: "shellscript",
|
||||
bat: "bat",
|
||||
cmd: "bat",
|
||||
sql: "sql",
|
||||
xml: "xml",
|
||||
};
|
||||
@@ -219,6 +221,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [
|
||||
blockComment: null,
|
||||
indent: { tabSize: 2, insertSpaces: true },
|
||||
},
|
||||
{
|
||||
id: "bat",
|
||||
name: "Batch",
|
||||
extensions: ["bat", "cmd"],
|
||||
uiMode: "Batch",
|
||||
lineComment: "@REM",
|
||||
blockComment: null,
|
||||
indent: { tabSize: 4, insertSpaces: true },
|
||||
},
|
||||
{
|
||||
id: "plaintext",
|
||||
name: "Plain Text",
|
||||
|
||||
@@ -96,13 +96,15 @@ export const commentLine: Command = (view) => {
|
||||
const changes: ChangeSpec[] = [];
|
||||
blocks.forEach((b, bi) => {
|
||||
const token = tokens[bi]!;
|
||||
// A word token (Batch `@REM`) needs a separator even when comment spacing is off.
|
||||
const addSpace = style.addSpace || /\w$/.test(token);
|
||||
if (allCommented) {
|
||||
for (let n = b.startLine; n <= b.endLine; n++) {
|
||||
const line = doc.line(n);
|
||||
const start = line.from + leadingWs(line.text);
|
||||
if (!doc.sliceString(start, line.to).startsWith(token) || !line.text.trim()) continue;
|
||||
let end = start + token.length;
|
||||
if (style.addSpace && doc.sliceString(end, end + 1) === " ") end++;
|
||||
if (addSpace && doc.sliceString(end, end + 1) === " ") end++;
|
||||
// Nothing but whitespace left: the line is emptied.
|
||||
if (!doc.sliceString(end, line.to).trim()) changes.push({ from: line.from, to: line.to });
|
||||
else changes.push({ from: start, to: end });
|
||||
@@ -111,7 +113,7 @@ export const commentLine: Command = (view) => {
|
||||
}
|
||||
const commentStart = b.caretUpdate === "commentStart";
|
||||
const prefixFor = (shifted: number, line: { to: number }) =>
|
||||
token + (style.addSpace && (shifted < line.to || (commentStart && shifted < doc.length)) ? " " : "");
|
||||
token + (addSpace && (shifted < line.to || (commentStart && shifted < doc.length)) ? " " : "");
|
||||
if (style.atFirstColumn) {
|
||||
for (let n = b.startLine; n <= b.endLine; n++) {
|
||||
const line = doc.line(n);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { insertNewlineAndIndent } from "@codemirror/commands";
|
||||
import { indentService, indentUnit } from "@codemirror/language";
|
||||
import { indentService, indentUnit, ensureSyntaxTree } from "@codemirror/language";
|
||||
import {
|
||||
addCaretsToLineEnds,
|
||||
commentLine,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
toggleCase,
|
||||
unselectOccurrence,
|
||||
} from "./editing";
|
||||
import { commentTokens } from "./languages";
|
||||
import { batchLanguage, commentTokens, findLanguageDescription } from "./languages";
|
||||
import { toggleCaret } from "./multiCaret";
|
||||
import { enterInLineComment, lineCommentStyle } from "./comments";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
@@ -338,3 +338,51 @@ describe("fallback indentation boundaries", () => {
|
||||
expect(show(v)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Batch language mode and @REM comment toggle", () => {
|
||||
it("resolves Batch by uiMode and .bat / .cmd extensions and toggles @REM comments", () => {
|
||||
expect(findLanguageDescription("Batch", "build.bat")?.name).toBe("Batch");
|
||||
expect(findLanguageDescription(null, "deploy.cmd")?.name).toBe("Batch");
|
||||
const v = make("echo| hello\n", batchLanguage);
|
||||
commentLine(v);
|
||||
expect(v.state.doc.toString()).toBe("@REM echo hello\n");
|
||||
v.dispatch({ selection: EditorSelection.cursor(0) });
|
||||
commentLine(v);
|
||||
expect(v.state.doc.toString()).toBe("echo hello\n");
|
||||
});
|
||||
|
||||
it("highlights keywords, labels, variables, strings and :: / REM comments", () => {
|
||||
const src = [
|
||||
":: header comment",
|
||||
"@REM line comment",
|
||||
"@echo off",
|
||||
"setlocal",
|
||||
'set "APP=demo"',
|
||||
"for %%i in (a b) do if exist %APP% call :sub !COUNT!",
|
||||
":sub",
|
||||
"rem note",
|
||||
"exit /b 0",
|
||||
"endlocal",
|
||||
].join("\n");
|
||||
const state = EditorState.create({ doc: src, extensions: batchLanguage });
|
||||
const tree = ensureSyntaxTree(state, state.doc.length, 5000)!;
|
||||
const tokens: Array<[string, string]> = [];
|
||||
tree.iterate({
|
||||
enter: (n) => {
|
||||
if (n.name !== "Document" && n.from < n.to) tokens.push([n.name, state.sliceDoc(n.from, n.to)]);
|
||||
},
|
||||
});
|
||||
const has = (tag: string, text: string) => tokens.some(([n, s]) => n.includes(tag) && s === text);
|
||||
expect(has("comment", ":: header comment")).toBe(true);
|
||||
expect(has("comment", "@REM line comment")).toBe(true);
|
||||
expect(has("comment", "rem note")).toBe(true);
|
||||
for (const kw of ["echo", "setlocal", "set", "for", "in", "do", "if", "exist", "call", "exit", "endlocal"]) {
|
||||
expect(has("keyword", kw)).toBe(true);
|
||||
}
|
||||
expect(has("string", '"APP=demo"')).toBe(true);
|
||||
expect(has("variableName", "%%i")).toBe(true);
|
||||
expect(has("variableName", "%APP%")).toBe(true);
|
||||
expect(has("variableName", "!COUNT!")).toBe(true);
|
||||
expect(has("labelName", ":sub")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -560,6 +560,7 @@ export const KEYWORD_BLOCK_LANGS = new Set([
|
||||
"r",
|
||||
"perl",
|
||||
"powershell",
|
||||
"bat",
|
||||
]);
|
||||
/** A line opening an indented body in `end` / layout languages (Ruby, Lua, Elixir, Haskell…). */
|
||||
const KEYWORD_HEAD =
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
LRLanguage,
|
||||
LanguageDescription,
|
||||
LanguageSupport,
|
||||
StreamLanguage,
|
||||
indentService,
|
||||
language as languageFacet,
|
||||
type StreamParser,
|
||||
} from "@codemirror/language";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
import { languages as languageData } from "@codemirror/language-data";
|
||||
@@ -58,18 +60,90 @@ export function uiModeForPath(path: string): string | null | undefined {
|
||||
|
||||
const supports = new Map<string, Promise<LanguageSupport | null>>();
|
||||
|
||||
const BATCH_KEYWORDS = new Set([
|
||||
"echo",
|
||||
"set",
|
||||
"if",
|
||||
"else",
|
||||
"goto",
|
||||
"call",
|
||||
"for",
|
||||
"in",
|
||||
"do",
|
||||
"exit",
|
||||
"setlocal",
|
||||
"endlocal",
|
||||
"not",
|
||||
"exist",
|
||||
"defined",
|
||||
"errorlevel",
|
||||
"shift",
|
||||
"pushd",
|
||||
"popd",
|
||||
"cd",
|
||||
"start",
|
||||
"pause",
|
||||
"title",
|
||||
"cls",
|
||||
]);
|
||||
|
||||
const batchParser: StreamParser<null> = {
|
||||
name: "bat",
|
||||
startState: () => null,
|
||||
token(stream) {
|
||||
if (stream.eatSpace()) return null;
|
||||
const lineStart = /^\s*$/.test(stream.string.slice(0, stream.start));
|
||||
if (lineStart) {
|
||||
if (stream.match(/^@?::.*$/)) return "comment";
|
||||
if (stream.match(/^@?rem(?:\s.*|$)/i)) return "comment";
|
||||
if (stream.match(/^:[A-Za-z0-9_.-]+/)) return "labelName";
|
||||
}
|
||||
if (stream.match(/^::.*$/)) return "comment";
|
||||
if (stream.match(/^"(?:[^"]|"")*(?:"|$)/)) return "string";
|
||||
if (
|
||||
stream.match(/^%%(?:~[a-zA-Z]*)?[a-zA-Z0-9_]|^%[^%\s]+%|^%(?:~[a-zA-Z]*[0-9]|\*|[0-9])|^![A-Za-z_][A-Za-z0-9_]*!/)
|
||||
)
|
||||
return "variableName";
|
||||
if ((lineStart || /\s/.test(stream.string[stream.start - 1]!)) && stream.match(/^:[A-Za-z0-9_.-]+/))
|
||||
return "labelName";
|
||||
if (stream.eat("@")) return "punctuation";
|
||||
if (stream.match(/^\d+\b/)) return "number";
|
||||
const word =
|
||||
(stream.start === 0 || !/[A-Za-z0-9_.]/.test(stream.string[stream.start - 1]!)) &&
|
||||
stream.match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
||||
if (word) {
|
||||
const lower = (typeof word === "object" ? word[0] : "").toLowerCase();
|
||||
return BATCH_KEYWORDS.has(lower) ? "keyword" : null;
|
||||
}
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
languageData: { commentTokens: { line: "@REM" } },
|
||||
};
|
||||
|
||||
export const batchLanguage = StreamLanguage.define(batchParser);
|
||||
|
||||
const batchLanguageDescription = LanguageDescription.of({
|
||||
name: "Batch",
|
||||
alias: ["bat", "cmd"],
|
||||
extensions: ["bat", "cmd"],
|
||||
support: new LanguageSupport(batchLanguage),
|
||||
});
|
||||
|
||||
const BUILTIN_MODES: readonly LanguageDescription[] = [...languageData, batchLanguageDescription];
|
||||
|
||||
export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null {
|
||||
if (uiMode) {
|
||||
const contributed = _languageMode(uiMode);
|
||||
if (contributed) return contributed;
|
||||
const exact = languageData.find((l) => l.name === uiMode);
|
||||
const exact = BUILTIN_MODES.find((l) => l.name === uiMode);
|
||||
if (exact) return exact;
|
||||
const loose = LanguageDescription.matchLanguageName(languageData, uiMode, false);
|
||||
const loose = LanguageDescription.matchLanguageName(BUILTIN_MODES, uiMode, false);
|
||||
if (loose) return loose;
|
||||
}
|
||||
return uiMode === null && infos
|
||||
? null
|
||||
: LanguageDescription.matchFilename(languageData, path.slice(path.lastIndexOf("/") + 1));
|
||||
: LanguageDescription.matchFilename(BUILTIN_MODES, path.slice(path.lastIndexOf("/") + 1));
|
||||
}
|
||||
|
||||
/** Loads (once) the CodeMirror language for `desc`. */
|
||||
|
||||
@@ -163,6 +163,9 @@ describe("context configuration", () => {
|
||||
expect(fileRole("src/main.rs")).toBe("main");
|
||||
expect(fileRole("cmd/main.go", "package main\nfunc main() {}")).toBe("main");
|
||||
expect(fileRole("tools/gen.py")).toBe("script");
|
||||
expect(fileRole("scripts/build.bat", undefined, true)).toBe("script");
|
||||
expect(fileRole("scripts/run.cmd", undefined, true)).toBe("script");
|
||||
expect(fileRole("scripts/build.bat", undefined, false)).toBeNull();
|
||||
expect(fileRole("README.md")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -196,6 +199,17 @@ describe("context configuration", () => {
|
||||
args: ["tools/gen.py"],
|
||||
});
|
||||
expect(contextConfig("scripts/build.sh", configs)).toMatchObject({ command: "sh", args: ["scripts/build.sh"] });
|
||||
expect(contextConfig("scripts/build.bat", configs, undefined, true)).toMatchObject({
|
||||
command: "cmd.exe",
|
||||
args: ["/c", "build.bat"],
|
||||
cwd: "scripts",
|
||||
});
|
||||
expect(contextConfig("run.cmd", configs, undefined, true)).toMatchObject({
|
||||
command: "cmd.exe",
|
||||
args: ["/c", "run.cmd"],
|
||||
cwd: null,
|
||||
});
|
||||
expect(contextConfig("scripts/build.bat", configs, undefined, false)).toBeNull();
|
||||
expect(contextConfig("docs/readme.md", configs)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* direct interpreter invocation for main files. Best effort, IDEA-style.
|
||||
*/
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
|
||||
export type FileRole = "test" | "main" | "script" | null;
|
||||
|
||||
export function fileRole(relPath: string, content?: string): FileRole {
|
||||
export function fileRole(relPath: string, content?: string, windows = isWindows()): FileRole {
|
||||
const name = relPath.split("/").pop() ?? relPath;
|
||||
if (
|
||||
/\.(test|spec)\.[cm]?[jt]sx?$/.test(name) ||
|
||||
@@ -23,6 +24,7 @@ export function fileRole(relPath: string, content?: string): FileRole {
|
||||
if (/\.(kt|java)$/.test(name) && content !== undefined && /\bfun\s+main\s*\(|static\s+void\s+main\s*\(/.test(content))
|
||||
return "main";
|
||||
if (/\.(py|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script";
|
||||
if (windows && /\.(bat|cmd)$/i.test(name)) return "script";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -194,7 +196,7 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function scriptConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null {
|
||||
function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWindows()): RunConfig | null {
|
||||
const base: RunConfig = {
|
||||
id: `context:file:${relPath}`,
|
||||
name: relPath.split("/").pop() ?? relPath,
|
||||
@@ -220,14 +222,23 @@ function scriptConfigFor(relPath: string, configs: RunConfig[]): RunConfig | nul
|
||||
const shell = relPath.endsWith(".zsh") ? "zsh" : relPath.endsWith(".bash") ? "bash" : "sh";
|
||||
return { ...base, command: shell, args: [relPath] };
|
||||
}
|
||||
if (windows && /\.(bat|cmd)$/i.test(relPath)) {
|
||||
const file = relPath.split("/").pop() ?? relPath;
|
||||
return { ...base, command: "cmd.exe", args: ["/c", file], cwd: dirOf(relPath) || null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Config to run for the file at `relPath` (relative to the project root), if any. */
|
||||
export function contextConfig(relPath: string, configs: RunConfig[], content?: string): RunConfig | null {
|
||||
const role = fileRole(relPath, content);
|
||||
export function contextConfig(
|
||||
relPath: string,
|
||||
configs: RunConfig[],
|
||||
content?: string,
|
||||
windows = isWindows(),
|
||||
): RunConfig | null {
|
||||
const role = fileRole(relPath, content, windows);
|
||||
if (role === "test") return testConfigFor(relPath, configs);
|
||||
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs);
|
||||
if (role === "script") return scriptConfigFor(relPath, configs);
|
||||
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows);
|
||||
if (role === "script") return scriptConfigFor(relPath, configs, windows);
|
||||
return null;
|
||||
}
|
||||
@@ -173,6 +173,17 @@ describe("single-file producers (scratches)", () => {
|
||||
"js-debug",
|
||||
);
|
||||
expect(run(scratch("tool", "#!/usr/bin/env python3\n"))).toMatchObject({ command: "/bin/python3", args: ["tool"] });
|
||||
expect(run(scratch("build.bat"), { ...ALL, windows: true })).toMatchObject({
|
||||
command: "cmd.exe",
|
||||
args: ["/c", "build.bat"],
|
||||
cwd: SCRATCH,
|
||||
});
|
||||
expect(run({ path: `${ROOT}/scripts/run.cmd`, root: ROOT, configs: [] }, { ...ALL, windows: true })).toMatchObject({
|
||||
command: "cmd.exe",
|
||||
args: ["/c", "run.cmd"],
|
||||
cwd: "scripts",
|
||||
});
|
||||
expect(produceContext(scratch("build.bat"), ALL)).toBeNull();
|
||||
expect(produceContext(scratch("notes", "just text"), ALL)).toBeNull();
|
||||
expect(shebang("#!/usr/bin/env node")).toEqual(["node"]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { currentRoots } from "@/platform/projectRoots";
|
||||
import { applicationConfig, jvmModuleDir } from "./application";
|
||||
import { formatArgs } from "./configs";
|
||||
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -154,6 +155,7 @@ export type ScriptLanguage =
|
||||
| "ts"
|
||||
| "python"
|
||||
| "shell"
|
||||
| "bat"
|
||||
| "ruby"
|
||||
| "php"
|
||||
| "perl"
|
||||
@@ -192,6 +194,7 @@ export function scriptLanguage(path: string, content?: string): ScriptLanguage |
|
||||
if (/\.[cm]?ts$/.test(name) && !/\.d\.[cm]?ts$/.test(name)) return "ts";
|
||||
if (/\.pyw?$/.test(name)) return "python";
|
||||
if (/\.(sh|bash|zsh)$/.test(name)) return "shell";
|
||||
if (/\.(bat|cmd)$/i.test(name)) return "bat";
|
||||
if (/\.rb$/.test(name)) return "ruby";
|
||||
if (/\.php$/.test(name)) return "php";
|
||||
if (/\.pl$/.test(name)) return "perl";
|
||||
@@ -229,6 +232,8 @@ export function toolsFor(lang: ScriptLanguage, content?: string): string[] {
|
||||
(n, i, all) => all.indexOf(n) === i,
|
||||
);
|
||||
}
|
||||
case "bat":
|
||||
return [];
|
||||
case "ruby":
|
||||
return ["ruby"];
|
||||
case "php":
|
||||
@@ -669,6 +674,14 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
config: fileConfig(s, "shell", t.command, [...(sb?.slice(1) ?? []), s.arg]),
|
||||
};
|
||||
}
|
||||
case "bat": {
|
||||
if (!(env.windows ?? isWindows())) return null;
|
||||
return {
|
||||
name: s.file,
|
||||
scope: "file",
|
||||
config: { ...fileConfig(s, "shell", "cmd.exe", ["/c", s.file]), cwd: dirName(s.idPath) || null },
|
||||
};
|
||||
}
|
||||
case "ruby":
|
||||
case "php":
|
||||
case "perl":
|
||||
|
||||
@@ -181,6 +181,8 @@ describe("fileIconName", () => {
|
||||
[".envrc", "file-shell"],
|
||||
["run.sh", "file-shell"],
|
||||
["build.ps1", "file-powershell"],
|
||||
["build.bat", "file-windows"],
|
||||
["run.cmd", "file-windows"],
|
||||
["server.log", "file-log"],
|
||||
["paper.tex", "file-latex"],
|
||||
["photo.PNG", "file-image"],
|
||||
|
||||
Reference in new issue
Заблокувати користувача