diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 85aa1ffad..10ab386e0 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -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"), diff --git a/languages/bat.toml b/languages/bat.toml new file mode 100644 index 000000000..5ce36ca33 --- /dev/null +++ b/languages/bat.toml @@ -0,0 +1,6 @@ +id = "bat" +name = "Batch" +extensions = ["bat", "cmd"] +line-comment = "@REM" +indent = { tab-size = 4, insert-spaces = true } +ui-mode = "Batch" diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index c93be8dd6..05d53647f 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -115,6 +115,8 @@ const EXT_LANG: Record = { 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", diff --git a/src/editor/core/cm/comments.ts b/src/editor/core/cm/comments.ts index fe22cc108..4509a61f2 100644 --- a/src/editor/core/cm/comments.ts +++ b/src/editor/core/cm/comments.ts @@ -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); diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index 3be938f42..326134312 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -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); + }); +}); diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts index 69594bc4f..f7fc7311a 100644 --- a/src/editor/core/cm/editing.ts +++ b/src/editor/core/cm/editing.ts @@ -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 = diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 7dc310fcf..07ac1b3af 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -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>(); +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 = { + 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`. */ diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 81026a41b..0f61d66c2 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -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(); }); diff --git a/src/features/run/context.ts b/src/features/run/context.ts index c7969b1a4..82f849cc2 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -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; } diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 5782b9060..d9ee794d1 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -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"]); }); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 117cca4b0..184cd6729 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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": diff --git a/src/ui/icons.test.tsx b/src/ui/icons.test.tsx index f2a97d747..72611ce46 100644 --- a/src/ui/icons.test.tsx +++ b/src/ui/icons.test.tsx @@ -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"],