@ai-markdown/code-language-detector
为没有语言标注的代码块做启发式语言检测,例如 LLM 输出中未标注语言的 Markdown 代码 fence。它返回的 id 可以直接交给 Shiki 或 highlight.js 使用。
主要使用场景是 agent 的流式输出:代码逐行到达,检测器必须在代码块完整之前给出可用的判定,并且中途不能反复改变判断,因为每次翻转都会让高亮闪烁。
指导原则是宁可说“不知道”,也不要猜错。证据不足时,结果是 language: null 加一份简短的候选列表,而不是强行选出一种语言。
- 零运行时依赖,同步执行,约 335 条正则规则覆盖 42 种语言。
- 流式检测器采用四种稳定性策略。在一个规则从未针对其调优过的真实文件 holdout 语料库上,2.4% 的文件在流式期间出现跨家族翻转;详见准确率与性能。
- 提供到 Shiki 与 highlight.js 语言名称的转换函数,以及几个规范化函数,把由人、模型或工具写出的语言名称映射为
CodeLanguage,或映射为高亮器使用的名称。
npm install @ai-markdown/code-language-detector双格式 ESM/CJS 构建:import 与 require 均可正常工作,两者均附带类型声明。
import { detectLanguage } from '@ai-markdown/code-language-detector';
const result = detectLanguage(code);// {// language: 'rust', // CodeLanguage.Rust,证据不足时为 null// confidence: 1, // 0..1;只有达到 0.8 及以上才会给出语言// candidates: ['rust'], // 最佳候选在前,最多四个,language 为 null 时同样存在// evidence: ['rs-fn', 'rs-let-mut', ...], // 规则 id,用于调试// }
const lang = result.language ?? 'text';language 是 CodeLanguage 枚举成员。它的取值就是 Shiki 语言 id,因此 result.language === 'rust' 与 result.language === CodeLanguage.Rust 是同一个判断。
import { StreamingLanguageDetector } from '@ai-markdown/code-language-detector';
const detector = new StreamingLanguageDetector();
// 每收到一个 chunk,都传入目前累积的完整代码,而不是增量。// 大多数调用直接返回缓存结果,不做任何检测。onChunk((accumulated) => { render(detector.update(accumulated).language ?? 'text');});
// fence 闭合时,基于完整内容再检测一次。onFenceClose((full) => { render(detector.finalize(full).language ?? 'text');});每个代码块使用一个检测器。以相同文本再次调用 update 或 finalize(例如重新渲染)时,会直接返回缓存结果,不再检测。
import { DetectionCache } from '@ai-markdown/code-language-detector';
const cache = new DetectionCache(500); // LRU,以内容哈希为键const result = cache.detect(code); // 相同内容只检测一次
import { detectLanguage, toHighlightJsLanguage, toShikiLanguage } from '@ai-markdown/code-language-detector';
const { language } = detectLanguage(code);
// Shiki:id 本身已经一致const html = language ? await codeToHtml(code, { lang: toShikiLanguage(language), theme }) : escape(code);
// highlight.js:少数名称不同,且对应的 grammar 可能尚未注册const name = language ? toHighlightJsLanguage(language) : null;const highlighted = name && hljs.getLanguage(name) ? hljs.highlight(code, { language: name }).value : escape(code);CodeLanguage | toHighlightJsLanguage | 原因 |
|---|---|---|
objective-c | objectivec | highlight.js 的名称 |
vb | vbnet | highlight.js 的名称 |
asm | x86asm | highlight.js 的名称 |
jsx / tsx | javascript / typescript | highlight.js 只把它们作为别名;这里写出完整名称,因此是否注册了别名不会产生影响 |
html, vue, svelte | xml | html 是 xml 的别名。highlight.js 没有 Vue 或 Svelte 的 grammar;xml 会把 <script> 作为 JavaScript、把 <style> 作为 CSS 子语言高亮,而 javascript 会把模板高亮得一团糟 |
zig | zig | highlight.js 未内置,但第三方 grammar 包会以这个名称注册;请检查 hljs.getLanguage,并回退为纯文本 |
| 其他所有语言 | 保持不变 |
规范化语言名称
Section titled “规范化语言名称”模型和人在代码 fence 上写的语言名称,高亮器未必采用同样的拼写,甚至可能完全不认识:objc、txt、Makefile、console。normalizeHighlightJsLanguage 与 normalizeShikiLanguage 把这类名称映射为高亮库实际使用的名称。两者都忽略大小写和首尾空白,并且总是返回字符串:
- 属于 42 种语言之一的名称,经
normalizeCodeLanguage和对应的转换函数解析:objc在 highlight.js 下变为objectivec,在 Shiki 下变为objective-c;vue在 highlight.js 下变为xml。 - 42 种语言之外、两个高亮器拼写不同的常见名称,由一张小表转换,覆盖纯文本、shell 会话、批处理文件、Makefile、CoffeeScript、Fortran、Delphi、Vim script、Jinja、Mathematica、Common Lisp、Protocol Buffers、补丁、Elixir、Perl、NDJSON 与 Objective-C++。
- 其他名称只转为小写,其余原样返回(
haskell、jsonc),因为它很可能是高亮器认识的语言。
| fence 上的名称 | normalizeHighlightJsLanguage | normalizeShikiLanguage |
|---|---|---|
objc | objectivec | objective-c |
vue | xml | vue |
txt、text、plain、空字符串 | plaintext | text |
console | shell | shellsession |
batch、bat、cmd | dos | bat |
Makefile | makefile | make |
coffee | coffeescript | coffee |
viml | vim | viml |
jinja2 | django | jinja |
proto | protobuf | proto |
patch | diff | diff |
ndjson | json | jsonl |
mm、objective-c++ | objectivec | objective-cpp |
haskell、jsonc(其他名称) | 仅转为小写 | 仅转为小写 |
import { detectLanguage, normalizeHighlightJsLanguage } from '@ai-markdown/code-language-detector';
// 带 info string 的 fence:信任它,只有没有 info string 时才检测const language = info || detectLanguage(code).language;const hljsName = language ? normalizeHighlightJsLanguage(language) : 'plaintext';const highlighted = hljs.getLanguage(hljsName) ? hljs.highlight(code, { language: hljsName }).value : escape(code);返回的是名称,而不是 grammar 已注册或已加载的保证:请检查 hljs.getLanguage(name) 或 Shiki 实例已加载的语言,并回退为纯文本。
normalizeCodeLanguage 回答的是另一个问题:某个名称指的是 42 种语言中的哪一种。它把由人或其他工具写出的名称(fence 的 info string、文件扩展名、highlight.js 或 Shiki 的名称及别名)解析为 CodeLanguage,不对应其中任何一种时返回 null。它同样忽略大小写和首尾空白,并且完全匹配的 CodeLanguage 取值总是优先于别名(html 解析为 Html,尽管 highlight.js 把 html 归在 xml 之下)。
import { normalizeCodeLanguage } from '@ai-markdown/code-language-detector';
normalizeCodeLanguage('py'); // CodeLanguage.PythonnormalizeCodeLanguage('x86asm'); // CodeLanguage.AssemblynormalizeCodeLanguage('haskell'); // null:不在这 42 种语言之内有歧义的名称会刻意解析为 null:m(Objective-C 或 MATLAB)、s、sc、conf、cfg、console(表示 shell 会话,而不是脚本)、sass(缩进语法并不是 SCSS)、gradle、jsp,以及 Objective-C++(mm)。h 解析为 C。jsonc 与 json5 也解析为 null:它们是 JSON 的超集,在 Shiki 中有各自的 grammar,而 highlight.js 把两者都注册为 json 的别名,因此原样交给高亮器对两者都适用。映射高亮器名称的两个函数会转换 console 和 Objective-C++ 的各种写法,并原样传递 jsonc 与 json5。
API 列表
Section titled “API 列表”| 导出符号 | 说明 |
|---|---|
detectLanguage(code) | 一次性检测,返回 LanguageDetectionResult |
StreamingLanguageDetector | update(code)、finalize(code)、reset()、current;选项见下 |
DetectionCache | new DetectionCache(limit = 500)、detect(code)、clear()、size |
toShikiLanguage(language) | Shiki 语言 id(原样返回) |
toHighlightJsLanguage(language) | highlight.js 语言名称(见上表) |
normalizeHighlightJsLanguage(name) | fence 上所写名称对应的 highlight.js 语言名称;不认识的名称只转为小写后返回 |
normalizeShikiLanguage(name) | fence 上所写名称对应的 Shiki 语言名称;不认识的名称只转为小写后返回 |
normalizeCodeLanguage(name) | 根据名称、扩展名或高亮器别名返回 CodeLanguage;不对应 42 种语言中的任何一种时返回 null |
CodeLanguage | 42 种语言的字符串枚举;取值为 Shiki id |
LanguageDetectionResult(类型) | { language: CodeLanguage | null; confidence; candidates: readonly CodeLanguage[]; evidence: readonly string[] } |
StreamingLanguageDetectorOptions(类型) | lockConfidence(0.9)、growthRatio(0.5)、minGrowthChars(80)、familySwitchMargin(0.1) |
结果对象在调用方之间共享(未知结果是冻结对象),请将其视为只读。
code → blank fenced code → rules score every language ─ high confidence ────────────→ language └ close relatives tie, the family is clear ──────────→ language at 0.8 └ ambiguous ───────────────────→ language: null, candidates: [2–4 languages] └ no evidence ─────────────────→ language: null, candidates: []JSON 单独处理:能被 JSON.parse 接受的对象或数组直接判定为 json,置信度 0.98。其他内容都由规则打分。超过 20,000 个字符的输入只检查开头和结尾各 10,000 个字符。
规则只有一种。 所谓“definitive”规则,就是权重较高并带有 definitive 标记的普通规则(<?php、let mut、System.out.println)。由于只有一条代码路径,永远不会出现“强规则命中了,但分数给出另一个结论”这种需要仲裁的情况。
definitive 规则只有在唯一时才会提升置信度。 只有当恰好存留一种 definitive 语言(被负分压到零以下的语言不计入),并且它同时也是得分最高的语言时,置信度才会被提升到 0.95。如果两种语言的强特征同时出现(例如 Python 文件中包含一段 SQL 字符串),说明证据自相矛盾,此时按普通打分处理。如果采用“第一条命中的 definitive 规则”,被提升的语言就会取决于规则文件的拼接顺序。
definitive 规则必须足够窄。 审查中发现的 0.95 误判几乎都来自写得过宽的 definitive 规则:SELECT … FROM 吞掉了 import { Select } from '…' 和 Drizzle 的 select().from();Verb-Noun 形式的 cmdlet 规则吞掉了 JS 中的 'Set-Cookie';activate$ 吞掉了 source venv/bin/activate;两行 int a = 1; 被当成了汇编的 int 中断指令;Eigen::Matrix<…> 匹配上了 Julia 的 ::Matrix。把规则标记为 definitive 之前,先排除这种写法在其他语言中最常见的相似形式(位于引号内、注释内,或不在命令位置)。
在多种语言中同样合法的语法,必须给这些语言打相同的分。 这是最重要的一条经验。int main() 在 C 和 C++ 中同样常见;给 C 打 7 分、给 C++ 打 5 分,就凭空制造出 2 分的差距,把一个本应有歧义的 #include <stdio.h> 片段变成高置信度的误判。同样的道理适用于 JS/TS 共有的 function foo(),以及 TS/Swift/Kotlin 共有的类型注解 (name: String)。只有真正能区分两种语言的规则,才可以给它们打不同的分。 差距应当由真正的区分特征补回来:小写的基本类型(: string、Map<string, number>)和 const x: T 只存在于 TS 中;: Int、init(、val 和 lateinit 只存在于 Swift 或 Kotlin 中。
大部分消歧由负权重完成。 interface Foo 给 typescript +9、给 javascript −8;JSX 标签给 jsx +9、给 javascript −4(JSX 无法作为普通 JS 运行)。如果只有正分,就无法区分“碰巧包含 interface 一词的 JS”与真正的 TS。
结构上不可能的情况使用 excludes,而不是负分。 负分表达的是倾向,足够多的正面证据可以累加超过它:Svelte 组件 <script lang="ts"> 块里的数百行 TS 会触发十几条 TS 规则,typescript 达到 80 分,而 svelte 只有 49 分,反向扣分设成 −6、−12 还是 −40 都只是赌博。然而,以 <script> 标签开头的片段在结构上不可能是 JS/TS 文件,因此这条规则用 excludes 把这些语言从排名中移除。由于 excludes 非常粗暴,规则卫生测试只允许在锚定于片段开头的规则上使用它(^,且没有 m flag)。目前共有三条:开头的 <script> 标签;开头的 <?php 标签(否则 namespace、docblock 和返回类型会让 TypeScript 的得分超过 PHP);以及开头的三引号 docstring,它不可能是 Markdown 文档。
注释和字符串中的代码不算证据。 agent 输出中充满了包含其他语言的注释和字符串:JSDoc 行 * Usage: <script src="x.js">、断言 toContain('<style>') 的测试、写着“把这段放进 <style> 标签”的 CSS 注释。因此,HTML 标签规则要求标签不在注释行上(行首不是 /*、*、// 或 #),并且不直接跟在引号之后。嵌入在 CI 配置中的 shell(run: | 块)属于同一类,会由 yaml-embedded-script 给 bash 打一个很大的负分,Dockerfile 中 RUN 之后的 shell 命令也是如此。由此还引出两项更广泛的措施。打分之前先清空 fenced 代码块的内容(fence 行本身保留):一份满是 TypeScript 示例的 Markdown 指南仍然是 Markdown,Python prompt 模板中用 fence 包裹的 JSON 示例也不是 JSON 的证据。连续的 /// 或 //! 文档注释会给 Markdown 扣分,因为这类注释里充满了 Markdown 的行内代码、强调和标题。
置信度不是分数的线性函数。 它是三项的加权和:分数(0.55)、相对第二名的差距(0.30),以及证据的分散程度(0.15)。单条证据的置信度上限为 0.72,非常短的片段还会打折扣。分数和差距都会先开平方,因此“刚刚越过阈值”就已经能得到合理的中等置信度。
平局时按流行度决定。 语言先按分数排名,再按独立证据的数量排名,最后按流行度排名;只有前两项完全相同时,流行度才起作用。排名采用 TIOBE 指数份额(取自 2026-09),它在相似语言之间指向正确的方向:JavaScript 2.76 > TypeScript 0.43,因此没有类型证据时平局判给 javascript;C 10.28 > C++ 8.67,因此单独的 #include 判给 c。两者都是更保守的选择。TIOBE 统计的是搜索结果,与代码 fence 中实际出现的内容差别很大(TypeScript 排在 Visual Basic 之后;bash、JSON、YAML 和 HTML 根本没有排名),因此 18 种未上榜的语言使用根据其在代码 fence 中的常见程度估算的值。修改这些值不会改变任何有证据支撑的判定。
打成平局的近亲语言仍然会得到判定。 当证据无法区分 C 与 C++、JavaScript 与 TypeScript,或 CSS 与 SCSS、Less 时,即使语言家族已经确定,最佳成员单独计算的置信度仍然偏低;如果在这里放弃判定,大部分真实的 C 代码和普通样式表都将得不到高亮。因此,当第二名与第一名同属这三个家族之一时,会改为以家族之外的最佳语言为对照重新计算置信度;如果结果越过判定线,就按上文的平局规则选出最佳成员,并以恰好 0.8(即判定线)给出该语言。0.8 表示“确定家族,但不确定具体成员”;它低于流式锁定阈值,因此之后出现的 #include <iostream> 或类型注解仍然可以细化判定。成员之间高亮差异较大的家族(YAML 与 INI、Bash 与 PowerShell、Java 与 Scala)不在家族内部猜测。
候选有绝对下限。 仅靠相对下限(最高分的 40%)是不够的:总分较低时,某条规则顺带给一种语言加的 1–2 分就可能越过它。候选还必须至少有 3 分,并且最多四个。
C 与 C++ 的区分模式以及 Objective-C 的区分模式,沿用了 GitHub Linguist 的 named_patterns.cpp 与 named_patterns.objectivec 启发式规则(MIT License),这些规则已在真实代码仓库上得到验证。
StreamingLanguageDetector 跟踪一段不断增长的文本,并采用四种策略:
- 置信度只升不降。 置信度更低的重新检测结果会被忽略,因此代码块中途证据被稀释时,判定不会退回
null。 - 切换家族需要超出差距。 当两次判定都越过高置信度线,却指向不同的语言家族时,说明证据自相矛盾;新判定必须比旧置信度高出
familySwitchMargin(0.1)。家族内部的细化(例如typescript → tsx)不受限制。 - 高置信度即锁定。 从
lockConfidence(0.9)起,追加的内容不再重新检测。 - 增长阈值。 在文本自上一个检查点起增长
minGrowthChars(80)个字符且增长growthRatio(50%)之前,update不查看文本,直接返回缓存结果。检查点按几何级数增长,因此整个流只需要少数几次检测。
finalize 会基于完整内容重新检测,但不会无条件覆盖:如果完整内容得不出语言,就保留流式期间的判定;家族内部的细化会被采纳;切换到其他家族仍然必须超出差距。早期版本允许 finalize 直接覆盖,结果 GitHub Actions 文件前 30 行一直稳定判定为 yaml,却因为 run: | 块中积累了足够多的 bash 证据,在 fence 闭合的那一刻跳成了 bash。
语言家族:JavaScript/TypeScript/JSX/TSX · C/C++/Objective-C · HTML/XML/Vue/Svelte · CSS/SCSS/Less · JSON/YAML/TOML/INI · Bash/PowerShell · Java/Kotlin/Groovy/Scala · MATLAB/Julia。家族内部的混淆对高亮几乎没有影响。
跟踪同一段文本。 如果输入不是所跟踪文本的延伸(startsWith 不成立),它就是另一段文本,检测器会重置。当调用方用 += 拼接文本时,即使只读取一个字符,V8 也会先把整段字符串展平,所以查看内容的耗时始终与文本长度成正比;如果每次调用都查看,逐 token 的流式输入会变成平方级开销(一个 200 KB 的代码块会耗时数秒而不是数毫秒)。因此检测器按几何级数的节奏查看内容:自上次检查以来,文本增长了 256 个字符,或增长量超过其长度的 1/32 时(取较大者),执行一次尾部检查,比较上一次检查过的输入的最后 64 个字符是否仍位于相同偏移处;比所跟踪文本更长的替换文本(例如重新生成的代码块)会在这段增长之内重置检测器。整段文本的比较在以下时机执行:输入不长于所跟踪的文本时、到达增长检查点时,以及 finalize 中,替换文本恰好重复了这 64 个字符的情况也会在这些时机被发现。整个流的总开销保持线性。finalize 之后,相同的文本直接返回缓存结果;延伸文本会以当前判定恢复流式检测;其他文本则会触发重置。
准确率与性能
Section titled “准确率与性能”本仓库中可以复现两组准确率数据:合成测试装置,由 fixture metrics 测试在每次运行时设置门禁;以及手工挑选的 GitHub 语料库,由 evidence harness 测量(见基准测试)。该语料库分为两份。tune 包含设计规则时参照过的所有文件;holdout 包含 504 个文件,42 种语言每种 12 个,来自 236 个采用宽松许可证的仓库,挑选时没有运行检测器,也从未用于设计规则。请以 holdout 的数字为准;调参集的数字会高估准确率。片段取自文件的前 8 到 35 行,这是最接近 agent 流式写入代码 fence 的形态;“宽松”口径还接受同一家族的语言(.ts 被检测为 javascript)。
合成测试装置(78 个模仿真实代码 fence 的样本,其中 19 个应当保持沉默)。由测试套件强制校验。
| 指标 | 数值 |
|---|---|
| False positive rate | 0% (0/19) |
| Precision | 100% (59/59) |
| Coverage | 75.6% (59/78) |
GitHub 语料库,一次性检测
| 数据集 | 片段数 | 检出率 | 严格 precision | 宽松 precision |
|---|---|---|---|---|
| holdout | 502 | 74.7% | 88.5% | 96.8% |
| tune | 625 | 78.9% | 94.1% | 99.6% |
GitHub 语料库,流式检测(前 40 行逐行输入;按文件前三个非空行去重,每种语言最多 25 个)
| 数据集 | 文件数 | 跨家族翻转 | 首次得到正确判定(p50 · p90) | 40 行内检出 | 最终判定正确 |
|---|---|---|---|---|---|
| holdout | 457 | 2.4% (11) | 第 7 行 · 第 27 行 | 85.8% | 90.8% |
| tune | 519 | 0% (0) | 第 6 行 · 第 24 行 | 89.0% | 92.3% |
两份数据之间的差距正是设置 holdout 的意义所在。原型在它唯一的真实语料库上报告了 0% 的跨家族翻转和 100% 的宽松 precision,而那个语料库同时也是规则调优所用的语料库。在未见过的代码上,约 3% 的一次性判定给出了其他家族的语言,2.4% 的流式文件在某一行出现过这样的判定。以这种方式发现的每一种形态(被当作 TypeScript 打分的 <?php 文件、被读成 YAML 的 Markdown front matter、被读成 Markdown 的 Rust 文档注释)都已修复,而下一份未见过的语料库又发现了其他形态。请把判错家族视为少见,而不是不可能。
手写样本系统性地偏向教科书风格。第一份真实语料库就暴露了以下问题:以 <script lang="ts"> 开头的 Svelte 组件、正文为 Markdown 的 Julia docstring、与 Go 的 package main 冲突的单段 Scala 包名,以及用 MASM 语法编写的 MS-DOS 汇编;合成测试装置一个都没有发现。请在 holdout 数据集上验证规则改动,在真实项目代码上验证新语言。
| 场景 | 耗时 | 来源 |
|---|---|---|
| 单次检测,典型 fence(8–35 行) | p50 0.32 ms · p95 0.6 ms | GitHub holdout,可复现 |
| 在 20 KB 上限处的单次检测 | 5.6–7.7 ms | 原型的本地语料库 |
逐行流式输入一个 fence 的全部检测工作(平均 7.9 KB,250 次 update 调用) | p50 3.0 ms · p90 8.4 ms · max 34.9 ms | GitHub holdout,可复现 |
测试环境为 Apple M3 Max 与 Node 24。对流式场景而言,最后一行才是关键:增长阈值按几何级数递增,锁定机制会停止重新检测,因此一个 fence 从流式输入到闭合期间检测器所做的全部工作,加起来只有几毫秒,并且分散在数秒的输出过程中。
以下两项优化经过测量后被否决:
- 字面量预检查(每条规则声明一个必需的字面量;输入中不含该字面量时跳过正则)。典型片段只会命中 2.2% 的规则,看起来这能跳过大部分工作,但原型只快了 1.2×:预检查必须确认某个字面量不存在,这意味着
String.includes要扫描整个输入,开销与正则处于同一量级。开销还均匀分布在 300 多条规则上(最昂贵的一条也只占 1.3%),不存在可以针对性优化的热点。 - 更低的截断上限。 在 6 KB 时,最坏的单次调用快了 2.4×,同家族准确率甚至略有上升,但 coverage 下降了 2 个百分点。20 KB 的调用在每个 fence 中只发生一次(在
finalize时),因此上限保持为 20 KB。
共 42 种语言,全部使用 Shiki 语言 id。* 标记拥有 definitive 特征的语言(一旦匹配即可确定语言,例如 <?php、let mut、tell application "…"、@import("std"))。
| 分组 | 语言 |
|---|---|
| C 家族 | c · cpp* · objective-c* |
| ECMAScript | javascript · typescript · jsx · tsx |
| JVM / .NET | java* · csharp* · kotlin* · groovy* · scala* |
| 现代类 C 语言 | go* · rust* · swift* · zig* · dart* |
| 标记 / 文档 | html* · xml* · markdown |
| 数据 / 配置 | json · yaml · toml* · ini |
| 样式表 | css · scss* · less* |
| 单文件组件 | vue* · svelte* |
| 独立语言 | sql* · vb* · python* · ruby* · matlab* · julia* · php* · asm* · lua · powershell* · bash* · applescript* · dockerfile* |
- 区分
.ts与.js需要类型语法。 没有类型注解的 TS 片段会按家族平局规则判定为javascript,置信度 0.8;可能是 C++ 的 C 代码(判定为c)和普通样式表(判定为css)同样如此。 - 在未见过的代码上,约 3% 的判定会给出错误的家族(holdout 数据集,一次性检测),2.4% 的流式文件在某一行出现过这样的判定。详见准确率与性能。
html与xml只靠规则区分。 highlight.js 对两者使用同一个 grammar,因此帮不上忙;就高亮而言,这种区分很少有影响。- GitHub 语料库上各语言的百分比波动很大:每种语言 12 个文件足以暴露结构性问题,但不足以确定精确的比率。只使用嵌套、不使用
@variables的 Less 文件与 SCSS 或 CSS 无法区分,但它仍然留在样式表家族之内。 - 流式期间在家族内部细化是正常现象。 TSX 文件在出现第一个 JSX 标签之前都是合法的 TypeScript,因此判定会从
typescript变为tsx。Shiki 的 TS grammar 能正确高亮这部分内容,所以这不算闪烁;测试只禁止跨家族翻转。 - 从文件中间截取的片段比文件开头更容易落入错误的家族:包含 SQL 字符串的 Python 切片、形似 JSON 的 Python dict 字面量、从
<style>块内部开始的 HTML 切片。这些切片确实就是另一种语言的内容。流式输入的 fence 从代码开头开始,因此不会出现这种情况。 - 不处理家族内部的混淆:
build.gradle.kts会被检测为groovy(Gradle DSL 块看起来完全相同),这对高亮几乎没有影响。
null是正常结果,不是错误。 大多数简短或通用的片段(npm install foo、单独一行class Shape {})会刻意保持null。请渲染为纯文本;如果你掌握额外信息(例如文件名),可以从candidates中挑选。不要默认把candidates[0]当作答案,那样会丢掉检测器专门为之设计的 precision。- 传入累积文本,而不是增量。 检测器无法跟踪增量:先
update('let x')再update(' = 1'),两次传入的是互不相关的文本。每个增量都会被当作一段新文本并重置检测器,因此判定永远不会超出单个增量所能提供的信息。 - 每个代码块使用一个检测器。 检测器跟踪的是单段不断增长的文本。复用它处理下一个 fence 是可行的(非延伸文本会触发重置),但更长的替换文本要等到下一次尾部检查(增长 256 个字符或文本长度的 1/32 以内)、增长检查点或
finalize才会被发现,在此之前仍保留旧文本的判定。如果你知道某个代码块已被重新生成或替换,请调用reset(),它不必等待上述检查。 - 置信度恰好为 0.8 表示家族判定。 0.8 的
c完全可能是 C++,0.8 的javascript可能是没有类型注解的 TypeScript,0.8 的css可能是 SCSS。它们的高亮几乎相同;如果你需要确切的成员,请把 0.8 视为“已知家族”,并查看candidates。 - fence 闭合时调用
finalize。 在此之前,判定可能只基于前缀,而已锁定的判定永远不会重新检测。在文本相同的重新渲染中重复调用finalize开销很小。 - 面对跨家族的新判定,除非它明显更好,否则
finalize会保留原有判定。 如果一个代码块流式期间被判定为 YAML,结束时 shell 内容多于 YAML,它仍然保持yaml。这是预期行为;如果你想要不受历史影响的一次性判定,请使用detectLanguage(full)。 html与xml容易混淆。 类 XHTML 片段或 SVG 片段可能返回其中任意一个。请把两者都映射到标记语言的 grammar,而不是根据两者的区别分支处理。normalizeCodeLanguage返回null表示“不在这 42 种之内”,而不是“不是真实存在的语言”。haskell和jsonc在两个高亮器中都是有效名称。要把 fence 的 info string 转换为高亮器名称,请改用normalizeHighlightJsLanguage或normalizeShikiLanguage:它们会转换两个高亮器拼写不同的名称,并原样传递其他名称;如果遇到null就回退为纯文本,这些名称就会丢失。- 转换函数返回的名称并不代表 grammar 已注册。
toShikiLanguage、toHighlightJsLanguage、normalizeShikiLanguage与normalizeHighlightJsLanguage返回的是名称,而不是保证:Shiki 需要把该语言加载到高亮器中,highlight.js 需要注册对应的 grammar(尤其是 Zig,它从未内置)。请检查hljs.getLanguage(name)或 Shiki 实例已加载的语言,并回退为纯文本。 - 不要修改结果对象。 结果会被缓存并按引用共享;未知结果是冻结对象,在严格模式下向其数组 push 元素会抛出异常。
GitHub 语料库上的测量是 evidence harness(src/evidence/*.evidence.ts),与 engine 存放「为门禁提供依据、本身不做门禁」的数据的方式相同:它们只打印表格、不做断言,不在测试套件的 include 范围内,也不属于发布包。
# 将手工挑选的 GitHub 语料库(调参集与留出集两份清单)下载到系统临时目录下的一个目录中node packages/code-language-detector/scripts/fetch-github-corpus.mjs [corpus-dir]
# 按语言统计一次性检测准确率,以及流式行为:首次得到正确判定所需行数、# 跨家族翻转、整文件开销和 finalize 准确率pnpm --filter @ai-markdown/code-language-detector evidence语料库分为两份,分别出报告:tune(scripts/github-tune.tsv)是设计规则时参照的文件;holdout(scripts/github-holdout.tsv)从不用于设计规则,判断一项改动能否泛化要看它的数字。只在调参集中寻找需要修复的问题。一旦某个 holdout 文件影响了规则的设计,它就成了调参数据:把它所在的清单移入 github-tune.tsv,并按该清单文件头中的标准重新整理一份 holdout,整理时不要对候选文件运行检测器。语料库目录默认为 os.tmpdir() 下的 code-language-detector-corpus;如果使用其他目录,把它传给下载脚本,并通过 CORPUS_DIR 传给 harness。CORPUS_FILES_PER_LANGUAGE 限制流式测量中每种语言的样本数(默认 25)。下载时写入 <split>/<language>/ 目录,只替换脚本自身管理的目录,其他内容保持不变;每个文件都按所在清单钉住的 commit 获取,因此多次运行测量的是同样的内容。
语料库文件属于各自的仓库,并继续适用这些仓库的许可证。它们只下载到本地用于测量,不要提交到本仓库。
- 在
src/rules/<language>.ts中添加一条DetectionRule;如果是新文件,在src/rules/index.ts中注册。 - 在
src/__tests__/fixtures.ts中添加一个正例样本,同时添加一个最容易与之混淆的语言的样本。 - 运行
pnpm --filter @ai-markdown/code-language-detector test。规则卫生测试会强制要求:id 唯一、不使用g/yflag、没有嵌套量词、definitive语言的分数为正、excludes只用于锚定在开头的规则,以及在病态输入上不会发生灾难性回溯。如果 false positive rate 高于 0、precision 低于 100%,或 coverage 低于其基线(coverage 提升时请同步提高基线),fixture metrics测试就会失败。 - 运行 evidence harness(见基准测试),确认两份数据集上的流式跨家族翻转率都没有上升。 这一步不可省略。宽泛的规则很容易修好一个用例却弄坏另一个:
md-heading(以#开头的行)曾把带注释块的 YAML 判成 markdown,ini-key-value(key = value)差点污染了一大批语言。两次都是流式测量发出了警报,单元测试和合成测试装置都没有发现。 - 针对改动涉及的语言,检查 holdout 的一次性检测表:coverage 可以变化,宽松 precision 不得下降。对于新语言,请把热门且采用宽松许可证的项目中承载业务逻辑的文件加入
scripts/github-tune.tsv和scripts/github-holdout.tsv(两份清单使用不同的仓库,每个文件都钉到固定 commit),在CodeLanguage中添加成员并在src/aliases.ts中添加其常见名称,在src/popularity.ts中为其设置流行度值(有测试检查每种语言都有该值),如果它有近亲语言,还要在src/families.ts中为其指定家族;转换函数的测试会检查 Shiki 是否打包了该 id,以及 highlight.js 是否认识映射后的名称。
添加规则之前,先问自己:这个模式是否高度体现该语言的特征,或者至少能大幅缩小候选范围?许多语言共有的关键字(if、for、while、class、return)不应写进规则。
把规则标记为 definitive 之前,先问自己:这种写法是否可能出现在其他语言的字符串、注释或 import 语句中?如果可能,先用后行断言、行首锚点或命令位置排除这些形式,否则就不要将其标记为 definitive。谨慎使用 i flag:大小写本身就是信息(FROM node:20 是 Dockerfile,from os 是 Python;$Name = 是 PowerShell,$name = 是 PHP)。
本包独立于 @ai-markdown/react 发布版本进行版本管理。规则变更可能改变某些输入的检测结果;测试装置门禁确保这些变更不会在测试装置上新增 false positive,也不会降低 precision。
MIT。C、C++ 与 Objective-C 的区分模式以及 MATLAB 的 % 注释规则源自 GitHub Linguist 的启发式规则(MIT);相关归属详见 LICENSE。