OpenPLC 编辑器内嵌了 Monaco Editor——即驱动 VS Code 的同一引擎——作为其文本代码编辑界面,支持四种 IEC 61131-3 及扩展语言:结构化文本 (ST)、指令表 (IL)、Python和C/C++。除了基础编辑功能外,该集成还将自定义 Monarch 分词器、上下文感知补全提供程序、调试时的内联值装饰、AI 辅助的内联补全、差异审查、拖放 POU 插入、Python LSP 和主题同步整合到一个 React 组件中,该组件充当应用程序内所有文本 PLC 编程的中枢神经系统。
架构概述
Monaco 集成被组织为编辑器域下的一个独立功能模块。每个关注点——语言定义、主题、补全、拖放、AI 和 LSP——都位于monaco/目录中,顶层index.tsx通过连接到 Zustand 存储的 React 副作用和回调将它们全部协调起来。
该组件的属性被刻意精简——只有path、name和language——因为所有丰富的上下文(POU 数据、变量、调试状态、设备信息)都是通过 Zustand 存储和平台提供程序钩子流入的。这种设计保持了组件公共接口的稳定,同时其内部数据订阅可以独立演进。
语言注册管道
Monaco 本身并不附带 IEC 61131-3 支持,因此 OpenPLC 使用 Monaco 的 Monarch 分词器系统将ST和IL注册为自定义语言。一个共享的registerLanguage工具负责处理这两步过程:首先调用monaco.languages.register()声明语言标识,然后通过setLanguageConfiguration和setMonarchTokensProvider绑定LanguageConfiguration(括号、自动闭合对、缩进规则)和IMonarchLanguage(分词器规则)。
注册管道在模块加载时即刻执行——index.tsx顶部的import './configs'会在任何组件渲染之前触发所有语言注册。这确保了在编辑器挂载的瞬间,Monaco 的语言服务就已经可用。
| 语言 | 文件扩展名 | MIME 类型 | 分词器类型 |
|---|---|---|---|
| 结构化文本 (ST) | .st | text/structured-text | Monarch (自定义) |
| 指令表 (IL) | .il | text/instruction-list | Monarch (自定义) |
| Python | .py | 内置 | Monaco 内置 + 代码片段 |
| C/C++ | .cpp | 内置 | Monaco 内置 + 自定义补全 |
结构化文本分词器深入剖析
ST 语言定义是项目中最全面的自定义分词器。它声明了超过 280 个关键字,涵盖结构声明(PROGRAM、FUNCTION_BLOCK、CLASS)、变量作用域(VAR_INPUT、VAR_OUTPUT、VAR_IN_OUT)、IEC 61131-3 原始类型(BOOL、DINT、LREAL)、泛型类型说明符(ANY_NUM、ANY_BIT)、访问修饰符(PUBLIC、OVERRIDE)、SFC 元素(STEP、TRANSITION、ACTION)、标准功能块(TON、CTU、RS),以及跨越数学、类型转换、字符串操作和位操作的 70 多个内置函数。
Monarch 分词器使用了一个状态机,为块注释(@commentBlock、@commentBlockC)、文档注释变体(@commentBlockDoc、@commentBlockCDoc)、单引号字符串(@stringSingle)、类型化数字字面量(@typedNumber)和编译指示/属性(@attribute)设置了专用状态。分词优先级经过精心排序:带标签的文档注释(//TODO:、(*FIXME:)在常规注释之前匹配,函数调用在常规标识符解析之前匹配,而成员访问点则拥有自己的delimiter.accessortoken 类。
分词器通过 Monarch 的cases指令利用优先级级联来解决标识符歧义:@localVariables→@keywords→@builtinFunctions→@operators→@standardFBVariables→@customDataTypeVariables→TRUE|FALSE→@default。这意味着动态注入的局部变量名始终优先于关键字,从而能够正确高亮可能与语言关键字同名的用户自定义名称。
动态分词器更新
ST 分词器并非静态——有三个导出函数会在运行时修改它,以反映当前的项目状态:
updateLocalVariablesInTokenizer(variableNames)— 将当前 POU 的变量名注入到
@localVariables分词器数组中,使它们作为keywordtoken 高亮显示(区别于普通标识符)。updateDataTypeVariablesInTokenizer(customDataTypes)— 从结构化数据类型中提取字段名,并将它们推入
@customDataTypeVariables,从而能够正确高亮结构体成员访问模式。updateEnumValuesInTokenizer(customDataTypes)— 从枚举数据类型中提取枚举值名称,并在分词器
root状态的最前面添加一条正则表达式规则,确保像Red、Running或Error这样的枚举字面量获得keyword高亮。
每个修改函数都会调用monaco.languages.setMonarchTokensProvider('st', language)以重新注册分词器,使 Monaco 在下一个空闲帧重新对所有可见的 ST 模型进行分词。
补全提供程序架构
Monaco 编辑器注册了特定语言的补全提供程序,这些程序聚合来自多个领域来源的建议。由于 ST/IL 和 C/C++ 在 OpenPLC 生态系统中本质上的不同,两者的架构也有所差异。
ST/IL 补全管道
对于 ST 和 IL,注册了一个单一的registerCompletionItemProvider,以.作为触发字符(启用功能块成员的点访问补全)。当提供程序触发时,它会聚合来自七个不同来源的建议,按标签去重,并返回合并后的列表:
| 优先级 | 提供程序 | 来源数据 | 触发条件 |
|---|---|---|---|
| 1 | fbSuggestions | 来自pous的自定义功能块 | 始终 |
| 2 | dataTypeSuggestions | 来自dataTypes的自定义数据类型 | 始终 |
| 3 | snippetsSTSuggestions | 预定义的 ST/IL 代码片段 | 始终 |
| 4 | variablesSuggestions | 当前 POU 的局部变量 | 始终 |
| 5 | globalVariablesSuggestions | 项目全局变量 | 始终 |
| 6 | librarySuggestions | 已加载的库定义 | 始终 |
| 7 | keywordsSuggestions | IEC 61131-3 关键字 | 始终 |
| 8 | identifiersSuggestions | 源码中的自由标识符 | 回退 |
点访问检测使用对(\w+)\.$的正则表达式匹配——如果前面的标识符解析为具有原始类型的变量(对照baseTypeSchema.options检查),提供程序将返回空列表以抑制不相关的成员建议。对于非原始类型(功能块、结构体),功能块和数据类型提供程序会提供适当的成员补全。
C/C++ 补全管道
C/C++ 补全结合了 Monaco 内置的 C++ 智能与 OpenPLC 特有的添加项。自定义提供程序分四个来源叠加:标准库补全(常见 C/C++ 头文件和函数)、代码片段补全(常见模式)、Arduino API 补全(当目标设备为 Arduino 开发板时有条件激活),以及解析的局部变量(通过基于正则表达式的声明解析从当前源码中提取)。
parseCppVariables函数使用两种正则表达式模式扫描整个编辑器内容:一种类型声明模式,匹配int、float、uint8_t、String及其他常见 C 类型;另一种参数列表模式,提取函数参数。这提供了轻量级的变量感知能力,而无需完整的 C++ 解析器。
编辑器生命周期与状态同步
编辑器的生命周期遵循由@monaco-editor/react包装器协调的精确顺序:handleEditorBeforeMount→ 模型创建 →handleEditorDidMount。状态在 Zustand 存储和 Monaco 模型之间双向流动。
挂载序列
模型-存储同步
组件维护着一个localText状态,它作为编辑器内容的可信来源。存在两条同步路径:
存储 → 编辑器:当
pous在外部发生更改(例如,通过撤销/重做操作或文件重新加载)时,一个副作用会检测到不匹配并更新localText,然后通过受控的value属性传播到编辑器。编辑器 → 存储:
onChange处理程序(在所考察的范围内未显示,但由架构隐含)在存储上调用updatePou,将文件标记为脏状态,并通过pushToHistory推入撤销历史记录。
isSyncingModelRef标志防止重入更新:当 AI 差异审查以编程方式将编辑应用到模型时,它会将该标志设置为true,以便 onChange 处理程序可以区分用户编辑和编程式编辑。
调试内联值装饰
当调试器处于活动状态且编辑器正在显示 ST 或 IL 代码时,Monaco 会渲染内联值装饰——即出现在变量名之后的小注释,显示其当前运行时值。这是图形编辑器中显示的值徽章在文本编辑器中的等效实现。
装饰管道分两个阶段工作:
位置扫描(
debugVarPositionsmemo):扫描模型的每一行,使用一个跟踪多行块注释状态的有状态注释剥离器剥离行注释(//)和块注释((*...*)和/*...*/)。对于每一行,它将调试值映射中的变量名与剥离后的文本进行匹配,记录列位置。重叠声明机制防止相同的字符范围被装饰两次。装饰应用(副作用):使用 Monaco 带有
inlineClassName: 'debug-inline-value'的after装饰选项,将扫描到的位置转换为IModelDeltaDecoration对象,这会将值文本渲染为跟随在变量名之后的 CSS 样式内联注释。
对于功能块 POU,前缀从${name}:变为${programName}:${fbVariableName}.,反映了调试协议中使用的限定变量路径。这意味着在调试Main程序中名为motor1的MotorFB 实例时,调试值的键名将是Main:motor1.Speed、Main:motor1.Running等。fbInstanceContextmemo 从fbSelectedInstance和fbDebugInstances存储 slice 中解析当前选定的 FB 实例。
当调试器可见时,编辑器还会进入只读模式,以防止在调试会话期间意外修改代码。
AI 集成与差异审查
Monaco 编辑器通过两种互补机制集成 AI 辅助,这两种机制都受hasAIAssistant平台能力和用户明确授权的管控:
内联补全:通过
aiPort.registerInlineCompletions()注册,该函数接收 Monaco 实例、POU 名称和语言。AI 端口处理幽灵文本建议的整个生命周期,而编辑器组件仅作为轻量级的协调器。差异审查:当 AI 生成全量主体编辑时,存储会接收一个
pendingDiffs条目,其中包含旧主体、新主体和计算出的差异块。编辑器使用renderDiffReview函数将这些差异块渲染为彩色装饰(已接受与待定)。用户可以与各个差异块进行交互——“保留”(接受)或“撤销”(拒绝)——并实现细粒度控制。拒绝一个差异块会通过applyAcceptedHunks仅应用保留的块来重建主体,更新编辑器模型,并针对新的基线重新计算剩余的差异块。
editorInstanceId计数器解决了一个微妙的时序 bug:在 Web 构建中,@monaco-editor/react会在选项卡切换时重新挂载底层编辑器(由key={path}驱动)。差异审查副作用需要重新附加到新的编辑器实例,但副作用的依赖项更改会在新编辑器的onMount回调之前触发。通过在handleEditorDidMount中递增editorInstanceId,副作用会在新编辑器准备就绪后重新运行。
主题管理
Monaco 的主题系统与应用程序的 CSS 主题解耦。通过ensureOpenplcThemes向 Monaco 注册了两个自定义的 OpenPLC 主题,并且applyThemeNow会根据shouldUseDarkMode在它们之间切换。主题应用发生在三个时间点:
挂载前(
handleEditorBeforeMount):将主题注册到 Monaco 的主题注册表中。挂载时(
handleEditorDidMount):应用与当前存储状态匹配的初始主题。暗黑模式更改时(副作用):每当
shouldUseDarkMode切换时重新应用主题。
模块级的didApplyInitialTheme标志防止冗余的初始应用,尽管当前代码在两个分支中都无条件应用了主题——这似乎是一种安全措施,确保无论挂载顺序如何,主题始终保持一致。
文件监视与外部更改检测
在具有hasFileWatcher能力的平台(Electron 构建)上,编辑器会监视其当前 POU 文件的外部修改。当检测到外部更改时,编辑器会检查文件是否处于已保存状态;如果是,它会从磁盘重新加载内容,使用parseTextualPouFromString(用于 ST/IL)或parseHybridPouFromString(用于 Python/C++)对其进行解析,并更新本地文本状态和存储。
此外,在每次挂载时,编辑器都会通过从磁盘读取文件并将其与当前存储值进行比较,主动检查外部更改——这处理了在编辑器选项卡关闭时文件被外部修改的情况。
拖放 POU 插入
drag-and-drop/st.ts模块导出parsePouToStText,该函数将 POU 结构(包括其名称、类型、语言、主体、文档和变量)转换为有效的 ST 文本。这支持了拖放操作,即可将 POU 从项目树拖动并放入 ST 编辑器中,自动在插入点格式化 POU 声明和主体。
Python LSP 集成
在具有hasPythonLSP能力的平台上,编辑器会初始化 Python 语言服务器协议连接,为 Python POU 提供实时诊断、悬停信息和转到定义功能。其生命周期为:
挂载时:
initPythonLSP(monacoInstance)启动 LSP 客户端,然后setupPythonLSPForEditor(editorInstance)连接特定的编辑器实例。卸载时:
cleanupPythonLSP()关闭客户端。模板注入:
injectPythonTemplateIfNeeded确保 Python POU 具有所需的样板结构。
在 Web 构建中(没有hasPythonLSP),仅运行模板注入——跳过 LSP 连接。
能力管控摘要
Monaco 编辑器的功能集根据声明的平台能力而有所不同。下表总结了哪些功能受管控及其方式:
| 功能 | 能力管控 | 不可用时的行为 |
|---|---|---|
| 文件监视 | hasFileWatcher | 无外部更改检测 |
| Python LSP | hasPythonLSP | 仅模板注入,无实时诊断 |
| AI 内联补全 | hasAIAssistant | 无幽灵文本建议 |
| AI 差异审查 | hasAIAssistant+ 授权 | 无差异装饰 |
| 唯一模型 URI | hasLocalFilesystem | 使用普通path代替${projectPath}${path} |
| Arduino API 补全 | deviceBoard不包含 "OpenPLC Runtime" | 无 Arduino 特定建议 |