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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 23:34:22 +08:00
1 джерело 1602e4b530
коміт 285c7187fb
12 files changed
+213 -14

No files matched your search

+7 -1
Переглянути файл
@@ -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(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "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"),
+6
Переглянути файл
@@ -0,0 +1,6 @@
id = "bat"
name = "Batch"
extensions = ["bat", "cmd"]
line-comment = "@REM"
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "Batch"
+11
Переглянути файл
@@ -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",
+4 -2
Переглянути файл
@@ -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);
+50 -2
Переглянути файл
@@ -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);
});
});
+1
Переглянути файл
@@ -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 =
+77 -3
Переглянути файл
@@ -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`. */
+14
Переглянути файл
@@ -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();
});
+17 -6
Переглянути файл
@@ -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;
}
+11
Переглянути файл
@@ -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"]);
});
+13
Переглянути файл
@@ -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":
+2
Переглянути файл
@@ -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"],