feat(editor): recognize AsciiDoc files with basic highlighting (#731)

Register .adoc/.asciidoc as AsciiDoc in the language catalog (backend registry and in-memory backend) with // line comments, and add a lightweight CodeMirror stream mode for headings, attributes, delimited blocks, emphasis, inline code, URLs and comments. Refs #708.

Co-authored-by: Bl0ck154 <36800583+Bl0ck154@users.noreply.github.com>
This commit is contained in:
Bl0ck
2026-11-02 10:01:04 +08:00
committed by GitHub
parent 2653021389
commit fb55847afa
6 changed files with 129 additions and 1 deletions
+8
View File
@@ -8,6 +8,7 @@ use std::sync::Arc;
/// Built-in definitions, embedded at compile time. Add new languages here.
const BUILTIN: &[(&str, &str)] = &[
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
("bat", include_str!("../../../languages/bat.toml")),
("c", include_str!("../../../languages/c.toml")),
("clojure", include_str!("../../../languages/clojure.toml")),
@@ -373,6 +374,7 @@ mod tests {
/// `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] = &[
"AsciiDoc",
"Batch",
"C",
"C++",
@@ -532,6 +534,12 @@ mod tests {
let reg = reg();
assert_eq!(detect(&reg, "src/main.rs"), "rust");
assert_eq!(detect(&reg, "a/b.TS"), "typescript");
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
assert_eq!(detect(&reg, path), "asciidoc");
}
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
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");
+6
View File
@@ -0,0 +1,6 @@
id = "asciidoc"
name = "AsciiDoc"
extensions = ["adoc", "asciidoc"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "AsciiDoc"
+11
View File
@@ -119,6 +119,8 @@ const EXT_LANG: Record<string, string> = {
go: "go",
json: "json",
md: "markdown",
adoc: "asciidoc",
asciidoc: "asciidoc",
css: "css",
html: "html",
yml: "yaml",
@@ -218,6 +220,15 @@ const MEM_LANGUAGES: LanguageInfo[] = [
blockComment: null,
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "asciidoc",
name: "AsciiDoc",
extensions: ["adoc", "asciidoc"],
uiMode: "AsciiDoc",
lineComment: "//",
blockComment: null,
indent: { tabSize: 2, insertSpaces: true },
},
{
id: "markdown",
name: "Markdown",
+50
View File
@@ -0,0 +1,50 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
type State = { block: string | null };
const parser: StreamParser<State> = {
startState: () => ({ block: null }),
copyState: ({ block }) => ({ block }),
token(stream, state) {
if (stream.sol()) {
const line = stream.string;
const delimiter = line.trim();
if (state.block) {
if (delimiter === state.block) {
state.block = null;
stream.skipToEnd();
return "meta";
}
stream.skipToEnd();
return "monospace";
}
if (/^={1,6}\s+\S/.test(line)) {
stream.skipToEnd();
return "heading";
}
if (/^:[\w-]+:/.test(line)) {
stream.skipToEnd();
return "attributeName";
}
if (["----", "....", "____", "++++"].includes(delimiter)) {
state.block = delimiter;
stream.skipToEnd();
return "meta";
}
if (/^\s*\/\//.test(line)) {
stream.skipToEnd();
return "comment";
}
}
if (stream.eatSpace()) return null;
if (stream.match(/^https?:\/\/[^\s[\]<>]+/)) return "link";
if (stream.match(/^\*[^*\n]+\*/)) return "strong";
if (stream.match(/^_[^_\n]+_/)) return "emphasis";
if (stream.match(/^\x60[^\x60\n]+\x60/)) return "monospace";
stream.next();
return null;
},
languageData: { commentTokens: { line: "//" } },
};
export const asciidocLanguage = StreamLanguage.define(parser);
+48
View File
@@ -3,6 +3,54 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { findLanguageDescription, loadSupport } from "./languages";
describe("language modes", () => {
it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => {
for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) {
const description = findLanguageDescription("AsciiDoc", path);
expect(description?.name).toBe("AsciiDoc");
const support = await loadSupport(description!);
expect(support?.language).toBeTruthy();
}
});
it("assigns highlight tags to AsciiDoc syntax", async () => {
const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!);
const doc = [
"= Guide",
":toc: left",
"----",
"const example = true;",
"----",
"*bold* _italic_ `code` https://example.com",
"// Comment",
].join("\n");
const highlighter = tagHighlighter([
{ tag: t.heading, class: "heading" },
{ tag: t.attributeName, class: "attribute" },
{ tag: t.meta, class: "meta" },
{ tag: t.monospace, class: "monospace" },
{ tag: t.strong, class: "strong" },
{ tag: t.emphasis, class: "emphasis" },
{ tag: t.link, class: "link" },
{ tag: t.comment, class: "comment" },
]);
const highlighted: [string, string][] = [];
highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => {
highlighted.push([doc.slice(from, to), style]);
});
expect(highlighted).toEqual([
["= Guide", "heading"],
[":toc: left", "attribute"],
["----", "meta"],
["const example = true;", "monospace"],
["----", "meta"],
["*bold*", "strong"],
["_italic_", "emphasis"],
["`code`", "monospace"],
["https://example.com", "link"],
["// Comment", "comment"],
]);
});
it("styles `?.` like `.` in JavaScript and TypeScript", async () => {
const highlighter = tagHighlighter([{ tag: t.derefOperator, class: "deref" }]);
for (const [mode, path] of [
+6 -1
View File
@@ -68,6 +68,11 @@ const OWN_MODES = [
filename: /^(?:CMakeLists\.txt|.*\.cmake(?:\.in)?)$/,
load: async () => new LanguageSupport((await import("./cmake")).cmakeLanguage),
}),
LanguageDescription.of({
name: "AsciiDoc",
extensions: ["adoc", "asciidoc"],
load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)),
}),
LanguageDescription.of({
name: "Shell",
load: async () => {
@@ -245,7 +250,7 @@ const DEDENT_STATEMENT = /^\s*(?:pass|break|continue|return)\b/;
const KEYWORD_OPEN_LANGS = new Set(["elixir", "ruby", "lua"]);
const KEYWORD_OPEN =
/\b(?:do(?:\s*\|[^|]*\|)?|then|->)$|^\s*(?:def|defp|defmodule|class|module|function|local\s+function|if|unless|elsif|elseif|else|while|until|for|case|begin|cond|with|receive|try)\b/;
const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "log", "diff", "ignore", "gitattributes"]);
const NO_INDENT_LANGS = new Set(["plaintext", "markdown", "asciidoc", "log", "diff", "ignore", "gitattributes"]);
function opensBlock(code: string, language: string | undefined): boolean {
if (!code) return false;