🚧 Improve HTTP language parser
This commit is contained in:
@@ -4,31 +4,17 @@
|
||||
|
||||
import {Extension} from '@codemirror/state';
|
||||
|
||||
import {httpRunButtonGutter, httpRunButtonTheme, httpRequestsField} from './widgets/run-gutter';
|
||||
import {httpRequestsField, httpRunButtonGutter, httpRunButtonTheme} from './widgets/run-gutter';
|
||||
|
||||
/**
|
||||
* 创建 HTTP Client 扩展
|
||||
*/
|
||||
export function createHttpClientExtension(): Extension[] {
|
||||
|
||||
const extensions: Extension[] = [];
|
||||
|
||||
// HTTP 语言解析器
|
||||
// extensions.push(httpLanguage);
|
||||
|
||||
// StateField:缓存 HTTP 请求解析结果
|
||||
extensions.push(httpRequestsField);
|
||||
|
||||
// 运行按钮 Gutter
|
||||
extensions.push(httpRunButtonGutter);
|
||||
extensions.push(httpRunButtonTheme);
|
||||
|
||||
|
||||
// TODO: 后续阶段添加
|
||||
// - 自动补全(可选)
|
||||
// - 变量支持(可选)
|
||||
|
||||
return extensions;
|
||||
return [
|
||||
httpRequestsField,
|
||||
httpRunButtonGutter,
|
||||
httpRunButtonTheme,
|
||||
] as Extension[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LRLanguage, LanguageSupport, foldNodeProp, foldInside, indentNodeProp } from '@codemirror/language';
|
||||
import { CompletionContext } from '@codemirror/autocomplete';
|
||||
import { parser } from './http.parser';
|
||||
import { httpHighlighting } from './http.highlight';
|
||||
|
||||
@@ -34,10 +33,8 @@ const httpParserWithMetadata = parser.configure({
|
||||
export const httpLanguage = LRLanguage.define({
|
||||
parser: httpParserWithMetadata,
|
||||
languageData: {
|
||||
|
||||
//自动闭合括号
|
||||
closeBrackets: { brackets: ['(', '[', '{', '"', "'"] },
|
||||
|
||||
// 单词字符定义
|
||||
wordChars: '-_',
|
||||
},
|
||||
@@ -45,65 +42,7 @@ export const httpLanguage = LRLanguage.define({
|
||||
|
||||
/**
|
||||
* HTTP Client 语言支持
|
||||
* 包含语法高亮、折叠、缩进、自动补全等完整功能
|
||||
*/
|
||||
export function http() {
|
||||
return new LanguageSupport(httpLanguage, [
|
||||
httpLanguage.data.of({
|
||||
autocomplete: httpCompletion,
|
||||
}),
|
||||
]);
|
||||
return new LanguageSupport(httpLanguage);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Client 自动补全
|
||||
*/
|
||||
function httpCompletion(context: CompletionContext) {
|
||||
const word = context.matchBefore(/\w*/);
|
||||
if (!word || (word.from === word.to && !context.explicit)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: [
|
||||
// HTTP 方法
|
||||
{ label: 'GET', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'POST', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'PUT', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'DELETE', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'PATCH', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'HEAD', type: 'keyword', detail: 'HTTP Method' },
|
||||
{ label: 'OPTIONS', type: 'keyword', detail: 'HTTP Method' },
|
||||
|
||||
// @ 规则
|
||||
{ label: '@json', type: 'keyword', detail: 'Body Type' },
|
||||
{ label: '@formdata', type: 'keyword', detail: 'Body Type' },
|
||||
{ label: '@urlencoded', type: 'keyword', detail: 'Body Type' },
|
||||
{ label: '@text', type: 'keyword', detail: 'Body Type' },
|
||||
{ label: '@res', type: 'keyword', detail: 'Response' },
|
||||
|
||||
// 常用 Headers
|
||||
{ label: 'content-type', type: 'property', detail: 'Header' },
|
||||
{ label: 'authorization', type: 'property', detail: 'Header' },
|
||||
{ label: 'accept', type: 'property', detail: 'Header' },
|
||||
{ label: 'user-agent', type: 'property', detail: 'Header' },
|
||||
{ label: 'host', type: 'property', detail: 'Header' },
|
||||
|
||||
// 常用 Content-Type
|
||||
{ label: '"application/json"', type: 'constant', detail: 'Content Type' },
|
||||
{ label: '"text/plain"', type: 'constant', detail: 'Content Type' },
|
||||
{ label: '"multipart/form-data"', type: 'constant', detail: 'Content Type' },
|
||||
{ label: '"application/x-www-form-urlencoded"', type: 'constant', detail: 'Content Type' },
|
||||
|
||||
// 布尔值
|
||||
{ label: 'true', type: 'constant', detail: 'Boolean' },
|
||||
{ label: 'false', type: 'constant', detail: 'Boolean' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出语言定义和高亮配置
|
||||
*/
|
||||
export { httpHighlighting };
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
// @formdata - 表单数据(属性必须用逗号分隔)
|
||||
// @urlencoded - URL 编码格式(属性必须用逗号分隔)
|
||||
// @text - 纯文本内容(使用 content 字段)
|
||||
// @res - 响应数据(属性必须用逗号分隔)
|
||||
//
|
||||
// 3. 注释:
|
||||
// 3. 响应数据:
|
||||
// 使用独立的 JSON 块
|
||||
// # Response 200 OK 234ms
|
||||
// { "code": 200, "message": "success" }
|
||||
//
|
||||
// 4. 注释:
|
||||
// # 单行注释
|
||||
//
|
||||
// 示例 1 - JSON 请求:
|
||||
@@ -54,6 +58,25 @@
|
||||
// content: "纯文本内容"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 示例 5 - 带响应数据:
|
||||
// POST "http://api.example.com/login" {
|
||||
// @json {
|
||||
// username: "admin",
|
||||
// password: "123456"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Response 200 OK 234ms 2025-10-11 10:30:25
|
||||
// {
|
||||
// "code": 200,
|
||||
// "message": "登录成功",
|
||||
// "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
// "data": {
|
||||
// "userId": 1001,
|
||||
// "username": "admin"
|
||||
// }
|
||||
// }
|
||||
|
||||
@skip { whitespace | LineComment }
|
||||
|
||||
@@ -61,7 +84,9 @@
|
||||
|
||||
item {
|
||||
RequestStatement |
|
||||
AtRule
|
||||
AtRule |
|
||||
JsonObject |
|
||||
JsonArray
|
||||
}
|
||||
|
||||
// HTTP 请求 - URL 必须是字符串
|
||||
@@ -89,8 +114,7 @@ AtRule {
|
||||
JsonRule |
|
||||
FormDataRule |
|
||||
UrlEncodedRule |
|
||||
TextRule |
|
||||
ResponseRule
|
||||
TextRule
|
||||
}
|
||||
|
||||
// @json 块:JSON 格式请求体(属性必须用逗号分隔)
|
||||
@@ -117,12 +141,6 @@ TextRule {
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// @res 块:响应数据(属性必须用逗号分隔)
|
||||
ResponseRule {
|
||||
@specialize[@name=ResKeyword]<AtKeyword, "@res">
|
||||
JsonBlock
|
||||
}
|
||||
|
||||
// 普通块结构(属性逗号可选)
|
||||
Block {
|
||||
"{" blockContent "}"
|
||||
@@ -166,14 +184,59 @@ value {
|
||||
identifier
|
||||
}
|
||||
|
||||
// JSON 属性值(支持嵌套对象)
|
||||
// JSON 属性值(严格的 JSON 语法:字符串必须用引号)
|
||||
jsonValue {
|
||||
StringLiteral |
|
||||
NumberLiteral |
|
||||
JsonBlock |
|
||||
identifier
|
||||
JsonTrue |
|
||||
JsonFalse |
|
||||
JsonNull
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 独立 JSON 语法(用于响应数据)
|
||||
// ===============================
|
||||
|
||||
// JSON 对象(独立的 JSON 块,不需要 @ 前缀)
|
||||
JsonObject {
|
||||
"{" jsonObjectContent? "}"
|
||||
}
|
||||
|
||||
jsonObjectContent {
|
||||
JsonMember ("," JsonMember)* ","?
|
||||
}
|
||||
|
||||
// JSON 成员(支持字符串键名和标识符键名)
|
||||
JsonMember {
|
||||
(StringLiteral | identifier) ":" JsonValue
|
||||
}
|
||||
|
||||
// JSON 数组
|
||||
JsonArray {
|
||||
"[" jsonArrayContent? "]"
|
||||
}
|
||||
|
||||
jsonArrayContent {
|
||||
JsonValue ("," JsonValue)* ","?
|
||||
}
|
||||
|
||||
// JSON 值(完整的 JSON 值类型)
|
||||
JsonValue {
|
||||
StringLiteral |
|
||||
NumberLiteral |
|
||||
JsonObject |
|
||||
JsonArray |
|
||||
JsonTrue |
|
||||
JsonFalse |
|
||||
JsonNull
|
||||
}
|
||||
|
||||
// JSON 字面量
|
||||
JsonTrue { @specialize[@name=True]<identifier, "true"> }
|
||||
JsonFalse { @specialize[@name=False]<identifier, "false"> }
|
||||
JsonNull { @specialize[@name=Null]<identifier, "null"> }
|
||||
|
||||
// Tokens
|
||||
@tokens {
|
||||
// 单行注释(# 开头到行尾)
|
||||
@@ -207,6 +270,8 @@ jsonValue {
|
||||
":" ","
|
||||
|
||||
"{" "}"
|
||||
|
||||
"[" "]"
|
||||
}
|
||||
|
||||
@external propSource httpHighlighting from "./http.highlight"
|
||||
|
||||
@@ -24,9 +24,6 @@ export const httpHighlighting = styleTags({
|
||||
// @text - 使用特殊类型
|
||||
"TextKeyword": t.special(t.typeName),
|
||||
|
||||
// @res - 使用命名空间(紫色系)
|
||||
"ResKeyword": t.namespace,
|
||||
|
||||
// @ 符号本身 - 使用元标记
|
||||
"AtKeyword": t.meta,
|
||||
|
||||
@@ -55,6 +52,23 @@ export const httpHighlighting = styleTags({
|
||||
// # 单行注释 - 行注释颜色
|
||||
"LineComment": t.lineComment,
|
||||
|
||||
// ========== JSON 语法(独立 JSON 块)==========
|
||||
// JSON 对象和数组
|
||||
"JsonObject": t.brace,
|
||||
"JsonArray": t.squareBracket,
|
||||
|
||||
// JSON 成员(属性名)
|
||||
"JsonMember/StringLiteral": t.definition(t.propertyName),
|
||||
"JsonMember/identifier": t.definition(t.propertyName),
|
||||
|
||||
// JSON 字面量值
|
||||
"True False": t.bool,
|
||||
"Null": t.null,
|
||||
|
||||
// JSON 值(确保字符串和数字正确高亮)
|
||||
"JsonValue/StringLiteral": t.string,
|
||||
"JsonValue/NumberLiteral": t.number,
|
||||
|
||||
// ========== 标点符号 ==========
|
||||
// 冒号 - 分隔符
|
||||
":": t.separator,
|
||||
@@ -64,4 +78,7 @@ export const httpHighlighting = styleTags({
|
||||
|
||||
// 花括号 - 大括号
|
||||
"{ }": t.brace,
|
||||
|
||||
// 方括号 - 方括号
|
||||
"[ ]": t.squareBracket,
|
||||
})
|
||||
|
||||
@@ -25,11 +25,19 @@ export const
|
||||
JsonKeyword = 28,
|
||||
JsonBlock = 29,
|
||||
JsonProperty = 30,
|
||||
FormDataRule = 32,
|
||||
FormDataKeyword = 33,
|
||||
UrlEncodedRule = 34,
|
||||
UrlEncodedKeyword = 35,
|
||||
TextRule = 36,
|
||||
TextKeyword = 37,
|
||||
ResponseRule = 38,
|
||||
ResKeyword = 39
|
||||
JsonTrue = 32,
|
||||
True = 33,
|
||||
JsonFalse = 34,
|
||||
False = 35,
|
||||
JsonNull = 36,
|
||||
Null = 37,
|
||||
FormDataRule = 38,
|
||||
FormDataKeyword = 39,
|
||||
UrlEncodedRule = 40,
|
||||
UrlEncodedKeyword = 41,
|
||||
TextRule = 42,
|
||||
TextKeyword = 43,
|
||||
JsonObject = 44,
|
||||
JsonMember = 45,
|
||||
JsonValue = 46,
|
||||
JsonArray = 49
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
import {httpHighlighting} from "./http.highlight"
|
||||
const spec_identifier = {__proto__:null,GET:10, POST:12, PUT:14, DELETE:16, PATCH:18, HEAD:20, OPTIONS:22, CONNECT:24, TRACE:26}
|
||||
const spec_AtKeyword = {__proto__:null,"@json":56, "@formdata":66, "@urlencoded":70, "@text":74, "@res":78}
|
||||
const spec_identifier = {__proto__:null,GET:10, POST:12, PUT:14, DELETE:16, PATCH:18, HEAD:20, OPTIONS:22, CONNECT:24, TRACE:26, true:66, false:70, null:74}
|
||||
const spec_AtKeyword = {__proto__:null,"@json":56, "@formdata":78, "@urlencoded":82, "@text":86}
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "'nQYQPOOOOQO'#C`'#C`O!WQPO'#CvO!WQPO'#C|O!WQPO'#DOO!WQPO'#DQO!WQPO'#DSOOQO'#Cu'#CuO!]QPO'#C_OOQO'#D['#D[OOQO'#DU'#DUQYQPOOO!bQPO'#CyOOQO,59b,59bOOQO,59h,59hOOQO,59j,59jOOQO,59l,59lOOQO,59n,59nOOQO'#Cj'#CjO!jQPO,58yOOQO-E7S-E7SOOQO'#C{'#C{O!oQPO'#CzO!tQPO'#DaOOQO,59e,59eO!|QPO,59eO#gQPO'#CnOOQO1G.e1G.eO#nQPO,59fO#|QPO,59{O$UQPO,59{OOQO1G/P1G/POOQO'#Cp'#CpO$^QPO'#CoOOQO'#DV'#DVO$cQPO'#D^O$jQPO,59YO$oQPO'#CrOOQO'#Db'#DbOOQO1G/Q1G/QOOQO,59r,59rO%^QPO1G/gOOQO-E7U-E7UO%fQPO,59ZOOQO-E7T-E7TOOQO1G.t1G.tOOQO,59^,59^P!eQPO'#DWOOQO'#D_'#D_O%tQPO1G.uOOQO7+$a7+$a",
|
||||
stateData: "&c~O}OSPOS~OTPOUPOVPOWPOXPOYPOZPO[PO]POlQOqROsSOuTOwUO~Oa[O~O_bO~O`hO!PeO~OajO~OelO~OhmO`!TX~O`oO~OlQOqROsSOuTOwUO!PpO~O`!QP~P#RO_vOa[O!PvO!SuO~O!PeO`!Ta~OhyO`!Ta~Oe{O~O`!QX~P#RO`}O~Og!OO`fXhfXlfXqfXsfXufXwfX!PfX~O!PeO`!Ti~O_!QOajO!P!QO!SuO~Oh!SO`cilciqcisciuciwci!Pci~O!Pg~",
|
||||
goto: "$k!VPPP!W![PPPPPPPPP!`PPP!c!i!mP!qPP!w#PPP#V#i#q#PP#PP#PP#PP#w#}$TPPP$ZP$_$bP$e$hTXOZTWOZRcWQkcR!Q{TrjsTqjsQvlR!Q{SXOZTrjsXVOZjsQ]QQ^RQ_SQ`TQaURvlQg[Vxmy!PXf[my!PQZORdZQsjR|sQngRznTYOZRtjR!R{Ri[Rwl",
|
||||
nodeNames: "⚠ LineComment Document RequestStatement Method GET POST PUT DELETE PATCH HEAD OPTIONS CONNECT TRACE Url StringLiteral } { Block Property PropertyName : NumberLiteral Unit , AtRule JsonRule AtKeyword JsonKeyword JsonBlock JsonProperty PropertyName FormDataRule FormDataKeyword UrlEncodedRule UrlEncodedKeyword TextRule TextKeyword ResponseRule ResKeyword",
|
||||
maxTerm: 52,
|
||||
states: "+WQYQPOOOOQO'#C`'#C`O!ZQPO'#CvO!ZQPO'#DSO!ZQPO'#DUO!ZQPO'#DWOOQO'#Cu'#CuO!`QPO'#C_O!|QPO'#D_O#TQPO'#DYOOQO'#Dh'#DhOOQO'#D`'#D`QYQPOOO#`QPO'#CyOOQO,59b,59bOOQO,59n,59nOOQO,59p,59pOOQO,59r,59rOOQO'#Cj'#CjO#hQPO,58yO#mQPO'#CrOOQO'#C|'#C|OOQO'#DO'#DOOOQO'#DQ'#DQO$[QPO'#DpOOQO,59y,59yO$dQPO,59yOOQO'#D['#D[O$iQPO'#DZO$nQPO'#DoOOQO,59t,59tO$vQPO,59tOOQO-E7^-E7^OOQO'#C{'#C{O${QPO'#CzO%QQPO'#DmOOQO,59e,59eO%YQPO,59eO%pQPO'#CnOOQO1G.e1G.eOOQO,59^,59^O%wQPO,5:[O&OQPO,5:[OOQO1G/e1G/eO!eQPO,59uO&WQPO,5:ZO&cQPO,5:ZOOQO1G/`1G/`O&kQPO,59fO'PQPO,5:XO'XQPO,5:XOOQO1G/P1G/POOQO'#Cp'#CpO'aQPO'#CoOOQO'#Da'#DaO'fQPO'#DjO'mQPO,59YOOQO,59},59}O'rQPO1G/vOOQO-E7a-E7aOOQO1G/a1G/aOOQO,5:O,5:OO'yQPO1G/uOOQO-E7b-E7bOOQO'#Dn'#DnOOQO1G/Q1G/QOOQO,59|,59|O(UQPO1G/sOOQO-E7`-E7`O(^QPO,59ZOOQO-E7_-E7_OOQO1G.t1G.tP!eQPO'#DcP(lQPO'#DdP#cQPO'#DbOOQO'#Dk'#DkO(tQPO1G.uOOQO7+$a7+$a",
|
||||
stateData: ")`~O!ZOSPOS~OTPOUPOVPOWPOXPOYPOZPO[PO]POaXOlQOwROySO{TO!QWO~Oa]O~O_bO~O_kOaXOqeOsfOugO!QWO!`dO~O!PiO~P!eO_lO`nO!]lO~O`tO!]qO~OavO~OgxOhfX!PfX`fXlfXwfXyfX{fX!]fX~OhyO!P!dX~O!P{O~Oe|O~Oh}O`!cX~O`!PO~Oe!QO~Oh!RO`!aX~O`!TO~OlQOwROySO{TO!]!UO~O`!^P~P%_O!P!da~P!eOh![O!P!da~O_lO!]lO`!ca~Oh!`O`!ca~O_!bOa]OqeOsfOugO!`dO~O!]qO`!aa~Oh!eO`!aa~Oe!gO~O`!^X~P%_O`!iO~O!P!di~P!eO_lO!]lO`!ci~O!]qO`!ai~O_!mOavO!]!mO!`dO~O_lO!]lO~Oh!oO`cilciwciyci{ci!]ci~O!]g~",
|
||||
goto: "&Z!ePPP!f!jPPPPPPPPP!nPPP!q!w!{P#PPP#^#fPP#l#{$T$ZP$ZP$ZP#fP#fP#fP$e$p$xPP$e%T%Z%a%g%mPPP%sP%w%zP%}&Q&T&WTYO[TVO[RcVQwcR!m!gT!Wv!XT!Vv!XYkWy|![!jQ!b!QR!m!gSYO[T!Wv!XXUO[v!XQ^QQ_RQ`SQaTR!b!QQs]V!d!R!e!lXr]!R!e!lYkWy|![!jR!b!QSYO[ZkWy|![!jQmXV!_}!`!kQhWU!Zy![!jR!^|Q[ORp[Q!XvR!h!XQ!SsR!f!SQzhR!]zQ!OmR!a!OTZO[R!YvR!n!gRu]R!c!QRoXRjW",
|
||||
nodeNames: "⚠ LineComment Document RequestStatement Method GET POST PUT DELETE PATCH HEAD OPTIONS CONNECT TRACE Url StringLiteral } { Block Property PropertyName : NumberLiteral Unit , AtRule JsonRule AtKeyword JsonKeyword JsonBlock JsonProperty PropertyName JsonTrue True JsonFalse False JsonNull Null FormDataRule FormDataKeyword UrlEncodedRule UrlEncodedKeyword TextRule TextKeyword JsonObject JsonMember JsonValue ] [ JsonArray",
|
||||
maxTerm: 66,
|
||||
nodeProps: [
|
||||
["isolate", 15,""],
|
||||
["openedBy", 16,"{"],
|
||||
["closedBy", 17,"}"]
|
||||
["openedBy", 16,"{",47,"["],
|
||||
["closedBy", 17,"}",48,"]"]
|
||||
],
|
||||
propSources: [httpHighlighting],
|
||||
skippedNodes: [0,1,27],
|
||||
repeatNodeCount: 3,
|
||||
tokenData: "+[~RjX^!spq!srs#hst%[tu%swx&[{|'y|})e}!O'y!O!P(S!Q![)S![!])j!b!c)o!c!}*g#R#S%s#T#o*g#o#p+Q#q#r+V#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~!xY}~X^!spq!s#y#z!s$f$g!s#BY#BZ!s$IS$I_!s$I|$JO!s$JT$JU!s$KV$KW!s&FU&FV!s~#kWOY#hZr#hrs$Ts#O#h#O#P$Y#P;'S#h;'S;=`%U<%lO#h~$YO_~~$]RO;'S#h;'S;=`$f;=`O#h~$iXOY#hZr#hrs$Ts#O#h#O#P$Y#P;'S#h;'S;=`%U;=`<%l#h<%lO#h~%XP;=`<%l#h~%aSP~OY%[Z;'S%[;'S;=`%m<%lO%[~%pP;=`<%l%[~%xU!P~tu%s}!O%s!Q![%s!c!}%s#R#S%s#T#o%s~&_WOY&[Zw&[wx$Tx#O&[#O#P&w#P;'S&[;'S;=`'s<%lO&[~&zRO;'S&[;'S;=`'T;=`O&[~'WXOY&[Zw&[wx$Tx#O&[#O#P&w#P;'S&[;'S;=`'s;=`<%l&[<%lO&[~'vP;=`<%l&[~'|Q!O!P(S!Q![)S~(VP!Q![(Y~(_R!S~!Q![(Y!g!h(h#X#Y(h~(kR{|(t}!O(t!Q![(z~(wP!Q![(z~)PP!S~!Q![(z~)XS!S~!O!P(Y!Q![)S!g!h(h#X#Y(h~)jOh~~)oOe~~)rR}!O){!c!}*U#T#o*U~*OQ!c!}*U#T#o*U~*ZSk~}!O*U!Q![*U!c!}*U#T#o*U~*nU!P~g~tu%s}!O%s!Q![%s!c!}*g#R#S%s#T#o*g~+VOa~~+[O`~",
|
||||
repeatNodeCount: 5,
|
||||
tokenData: "+l~RlX^!ypq!yrs#nst%btu%ywx&b{|(P|})k}!O(P!O!P(Y!Q![)Y![!])p!b!c)u!c!}*m!}#O+W#P#Q+]#R#S%y#T#o*m#o#p+b#q#r+g#y#z!y$f$g!y#BY#BZ!y$IS$I_!y$I|$JO!y$JT$JU!y$KV$KW!y&FU&FV!y~#OY!Z~X^!ypq!y#y#z!y$f$g!y#BY#BZ!y$IS$I_!y$I|$JO!y$JT$JU!y$KV$KW!y&FU&FV!y~#qWOY#nZr#nrs$Zs#O#n#O#P$`#P;'S#n;'S;=`%[<%lO#n~$`O_~~$cRO;'S#n;'S;=`$l;=`O#n~$oXOY#nZr#nrs$Zs#O#n#O#P$`#P;'S#n;'S;=`%[;=`<%l#n<%lO#n~%_P;=`<%l#n~%gSP~OY%bZ;'S%b;'S;=`%s<%lO%b~%vP;=`<%l%b~&OU!]~tu%y}!O%y!Q![%y!c!}%y#R#S%y#T#o%y~&eWOY&bZw&bwx$Zx#O&b#O#P&}#P;'S&b;'S;=`'y<%lO&b~'QRO;'S&b;'S;=`'Z;=`O&b~'^XOY&bZw&bwx$Zx#O&b#O#P&}#P;'S&b;'S;=`'y;=`<%l&b<%lO&b~'|P;=`<%l&b~(SQ!O!P(Y!Q![)Y~(]P!Q![(`~(eR!`~!Q![(`!g!h(n#X#Y(n~(qR{|(z}!O(z!Q![)Q~(}P!Q![)Q~)VP!`~!Q![)Q~)_S!`~!O!P(`!Q![)Y!g!h(n#X#Y(n~)pOh~~)uOe~~)xR}!O*R!c!}*[#T#o*[~*UQ!c!}*[#T#o*[~*aSk~}!O*[!Q![*[!c!}*[#T#o*[~*tU!]~g~tu%y}!O%y!Q![%y!c!}*m#R#S%y#T#o*m~+]O!Q~~+bO!P~~+gOa~~+lO`~",
|
||||
tokenizers: [0],
|
||||
topRules: {"Document":[0,2]},
|
||||
specialized: [{term: 47, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1},{term: 27, get: (value: keyof typeof spec_AtKeyword) => spec_AtKeyword[value] || -1}],
|
||||
tokenPrec: 246
|
||||
specialized: [{term: 59, get: (value: keyof typeof spec_identifier) => spec_identifier[value] || -1},{term: 27, get: (value: keyof typeof spec_AtKeyword) => spec_AtKeyword[value] || -1}],
|
||||
tokenPrec: 381
|
||||
})
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
/**
|
||||
* HTTP Client 语言支持
|
||||
*
|
||||
* 基于 CSS 语法结构的简化 HTTP 请求语言
|
||||
*
|
||||
* 语法示例:
|
||||
* ```http
|
||||
* POST http://127.0.0.1:80/api/create {
|
||||
* host: "https://api.example.com";
|
||||
* content-type: "application/json";
|
||||
*
|
||||
* @json {
|
||||
* name: "xxx";
|
||||
* }
|
||||
*
|
||||
* @res {
|
||||
* code: 200;
|
||||
* status: "ok";
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { http, httpLanguage, httpHighlighting } from './http-language';
|
||||
export { http, httpLanguage } from './http-language';
|
||||
export { parser } from './http.parser';
|
||||
|
||||
// 类型定义
|
||||
export type { LRLanguage } from '@codemirror/language';
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState, ChangeSpec } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { blockState } from '../../codeblock/state';
|
||||
|
||||
/**
|
||||
* 响应数据模型
|
||||
*/
|
||||
export interface HttpResponse {
|
||||
/** 状态码 */
|
||||
status: number;
|
||||
|
||||
/** 状态文本 */
|
||||
statusText: string;
|
||||
|
||||
/** 响应时间(毫秒) */
|
||||
time: number;
|
||||
|
||||
/** 响应体 */
|
||||
body: any;
|
||||
|
||||
/** 时间戳 */
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型常量
|
||||
*/
|
||||
const NODE_TYPES = {
|
||||
REQUEST_STATEMENT: 'RequestStatement',
|
||||
LINE_COMMENT: 'LineComment',
|
||||
JSON_OBJECT: 'JsonObject',
|
||||
JSON_ARRAY: 'JsonArray',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 响应插入位置信息
|
||||
*/
|
||||
interface InsertPosition {
|
||||
/** 插入位置 */
|
||||
from: number;
|
||||
|
||||
/** 删除结束位置(如果需要删除旧响应) */
|
||||
to: number;
|
||||
|
||||
/** 是否需要删除旧响应 */
|
||||
hasOldResponse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 响应插入器
|
||||
*/
|
||||
export class HttpResponseInserter {
|
||||
constructor(private view: EditorView) {}
|
||||
|
||||
/**
|
||||
* 在请求后插入响应数据
|
||||
* @param requestPos 请求的起始位置
|
||||
* @param response 响应数据
|
||||
*/
|
||||
insertResponse(requestPos: number, response: HttpResponse): void {
|
||||
const state = this.view.state;
|
||||
const insertPos = this.findInsertPosition(state, requestPos);
|
||||
|
||||
if (!insertPos) {
|
||||
console.error('no insert position');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成响应文本
|
||||
const responseText = this.formatResponse(response);
|
||||
|
||||
// 创建变更
|
||||
const changes: ChangeSpec = {
|
||||
from: insertPos.from,
|
||||
to: insertPos.to,
|
||||
insert: responseText
|
||||
};
|
||||
|
||||
// 应用变更
|
||||
this.view.dispatch({
|
||||
changes,
|
||||
// 将光标移动到插入内容的末尾
|
||||
selection: { anchor: insertPos.from + responseText.length },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插入位置
|
||||
* 规则:
|
||||
* 1. 在当前请求后面
|
||||
* 2. 在下一个请求前面
|
||||
* 3. 如果已有响应(# Response 开头),删除旧响应
|
||||
*/
|
||||
private findInsertPosition(state: EditorState, requestPos: number): InsertPosition | null {
|
||||
const tree = syntaxTree(state);
|
||||
const blocks = state.field(blockState, false);
|
||||
|
||||
if (!blocks) return null;
|
||||
|
||||
// 找到当前 HTTP 块
|
||||
const currentBlock = blocks.find(block =>
|
||||
block.language.name === 'http' &&
|
||||
block.content.from <= requestPos &&
|
||||
requestPos <= block.content.to
|
||||
);
|
||||
|
||||
if (!currentBlock) return null;
|
||||
|
||||
const context = this.findInsertionContext(
|
||||
tree,
|
||||
state,
|
||||
requestPos,
|
||||
currentBlock.content.from,
|
||||
currentBlock.content.to
|
||||
);
|
||||
|
||||
if (!context.requestNode) return null;
|
||||
|
||||
const requestEnd = context.requestNode.to;
|
||||
|
||||
if (context.oldResponse) {
|
||||
// 如果有旧响应,精确替换(从上一行的末尾到响应末尾)
|
||||
const oldResponseStartLine = state.doc.lineAt(context.oldResponse.from);
|
||||
const prevLineNum = oldResponseStartLine.number - 1;
|
||||
|
||||
let deleteFrom = context.oldResponse.from;
|
||||
if (prevLineNum >= 1) {
|
||||
const prevLine = state.doc.line(prevLineNum);
|
||||
deleteFrom = prevLine.to; // 从上一行的末尾开始删除
|
||||
}
|
||||
|
||||
return {
|
||||
from: deleteFrom,
|
||||
to: context.oldResponse.to,
|
||||
hasOldResponse: true
|
||||
};
|
||||
} else {
|
||||
// 如果没有旧响应,在请求后面插入
|
||||
const requestEndLine = state.doc.lineAt(requestEnd);
|
||||
|
||||
// 在当前行末尾插入(formatResponse 会自动添加必要的换行)
|
||||
return {
|
||||
from: requestEndLine.to,
|
||||
to: requestEndLine.to,
|
||||
hasOldResponse: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次遍历查找插入上下文
|
||||
*/
|
||||
private findInsertionContext(
|
||||
tree: any,
|
||||
state: EditorState,
|
||||
requestPos: number,
|
||||
blockFrom: number,
|
||||
blockTo: number
|
||||
): {
|
||||
requestNode: SyntaxNode | null;
|
||||
nextRequestPos: number | null;
|
||||
oldResponse: { from: number; to: number } | null;
|
||||
} {
|
||||
let requestNode: SyntaxNode | null = null;
|
||||
let nextRequestPos: number | null = null;
|
||||
let responseStartNode: SyntaxNode | null = null;
|
||||
let responseEndPos: number | null = null;
|
||||
|
||||
// 第一步:向上查找当前请求节点
|
||||
const cursor = tree.cursorAt(requestPos);
|
||||
do {
|
||||
if (cursor.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
if (cursor.from >= blockFrom && cursor.to <= blockTo) {
|
||||
requestNode = cursor.node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (cursor.parent());
|
||||
|
||||
// 如果向上查找失败,从块开始位置查找
|
||||
if (!requestNode) {
|
||||
const blockCursor = tree.cursorAt(blockFrom);
|
||||
do {
|
||||
if (blockCursor.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
if (blockCursor.from <= requestPos && requestPos <= blockCursor.to) {
|
||||
requestNode = blockCursor.node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (blockCursor.next() && blockCursor.from < blockTo);
|
||||
}
|
||||
|
||||
if (!requestNode) {
|
||||
return { requestNode: null, nextRequestPos: null, oldResponse: null };
|
||||
}
|
||||
|
||||
const requestEnd = requestNode.to;
|
||||
|
||||
// 第二步:从请求结束位置向后遍历,查找响应和下一个请求
|
||||
const forwardCursor = tree.cursorAt(requestEnd);
|
||||
let foundResponse = false;
|
||||
|
||||
do {
|
||||
if (forwardCursor.from <= requestEnd) continue;
|
||||
if (forwardCursor.from >= blockTo) break;
|
||||
|
||||
// 查找下一个请求
|
||||
if (!nextRequestPos && forwardCursor.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
nextRequestPos = forwardCursor.from;
|
||||
// 如果已经找到响应,可以提前退出
|
||||
if (foundResponse) break;
|
||||
}
|
||||
|
||||
// 查找响应注释
|
||||
if (!responseStartNode && forwardCursor.name === NODE_TYPES.LINE_COMMENT) {
|
||||
const commentText = state.doc.sliceString(forwardCursor.from, forwardCursor.to);
|
||||
// 避免不必要的 trim
|
||||
if (commentText.startsWith('# Response') || commentText.startsWith(' # Response')) {
|
||||
const startNode = forwardCursor.node;
|
||||
responseStartNode = startNode;
|
||||
foundResponse = true;
|
||||
|
||||
// 继续查找 JSON 和结束分隔线
|
||||
let nextNode = startNode.nextSibling;
|
||||
while (nextNode && nextNode.from < (nextRequestPos || blockTo)) {
|
||||
// 找到 JSON
|
||||
if (nextNode.name === NODE_TYPES.JSON_OBJECT || nextNode.name === NODE_TYPES.JSON_ARRAY) {
|
||||
responseEndPos = nextNode.to;
|
||||
|
||||
// 查找结束分隔线
|
||||
let afterJson = nextNode.nextSibling;
|
||||
while (afterJson && afterJson.from < (nextRequestPos || blockTo)) {
|
||||
if (afterJson.name === NODE_TYPES.LINE_COMMENT) {
|
||||
const text = state.doc.sliceString(afterJson.from, afterJson.to);
|
||||
// 使用更快的正则匹配
|
||||
if (/^#?\s*-+$/.test(text)) {
|
||||
responseEndPos = afterJson.to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
afterJson = afterJson.nextSibling;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 遇到下一个请求,停止
|
||||
if (nextNode.name === NODE_TYPES.REQUEST_STATEMENT) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextNode = nextNode.nextSibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (forwardCursor.next() && forwardCursor.from < blockTo);
|
||||
|
||||
// 构建旧响应信息
|
||||
let oldResponse: { from: number; to: number } | null = null;
|
||||
if (responseStartNode) {
|
||||
const startLine = state.doc.lineAt(responseStartNode.from);
|
||||
if (responseEndPos !== null) {
|
||||
const endLine = state.doc.lineAt(responseEndPos);
|
||||
oldResponse = { from: startLine.from, to: endLine.to };
|
||||
} else {
|
||||
const commentEndLine = state.doc.lineAt(responseStartNode.to);
|
||||
oldResponse = { from: startLine.from, to: commentEndLine.to };
|
||||
}
|
||||
}
|
||||
|
||||
return { requestNode, nextRequestPos, oldResponse };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 格式化响应数据
|
||||
*/
|
||||
private formatResponse(response: HttpResponse): string {
|
||||
const timestamp = response.timestamp || new Date();
|
||||
const dateStr = this.formatTimestamp(timestamp);
|
||||
|
||||
// 构建响应头行(不带分隔符)
|
||||
const headerLine = `# Response ${response.status} ${response.statusText} ${response.time}ms ${dateStr}`;
|
||||
|
||||
// 完整的开头行(只有响应头,不带分隔符)
|
||||
const header = `\n${headerLine}\n`;
|
||||
|
||||
// 格式化响应体
|
||||
let body: string;
|
||||
if (typeof response.body === 'string') {
|
||||
// 尝试解析 JSON 字符串
|
||||
try {
|
||||
const parsed = JSON.parse(response.body);
|
||||
body = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// 如果不是 JSON,直接使用字符串
|
||||
body = response.body;
|
||||
}
|
||||
} else if (response.body === null || response.body === undefined) {
|
||||
// 空响应(只有响应头和结束分隔线)
|
||||
const endLine = `# ${'-'.repeat(headerLine.length - 2)}`; // 减去 "# " 的长度
|
||||
return header + endLine;
|
||||
} else {
|
||||
// 对象或数组
|
||||
body = JSON.stringify(response.body, null, 2);
|
||||
}
|
||||
|
||||
// 结尾分隔线:和响应头行长度完全一致
|
||||
const endLine = `# ${'-'.repeat(headerLine.length - 2)}`; // 减去 "# " 的长度
|
||||
|
||||
return header + body + `\n${endLine}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
*/
|
||||
private formatTimestamp(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数:插入响应数据
|
||||
*/
|
||||
export function insertHttpResponse(view: EditorView, requestPos: number, response: HttpResponse): void {
|
||||
const inserter = new HttpResponseInserter(view);
|
||||
inserter.insertResponse(requestPos, response);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { EditorView, GutterMarker, gutter } from '@codemirror/view';
|
||||
import { StateField } from '@codemirror/state';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { blockState } from '../../codeblock/state';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import { parseHttpRequest, type HttpRequest } from '../parser/request-parser';
|
||||
import { insertHttpResponse, type HttpResponse } from '../parser/response-inserter';
|
||||
import { createDebounce } from '@/common/utils/debounce';
|
||||
|
||||
/**
|
||||
* 语法树节点类型常量
|
||||
@@ -15,21 +16,6 @@ const NODE_TYPES = {
|
||||
BLOCK: 'Block',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 有效的 HTTP 方法列表
|
||||
*/
|
||||
const VALID_HTTP_METHODS = new Set([
|
||||
'GET',
|
||||
'POST',
|
||||
'PUT',
|
||||
'DELETE',
|
||||
'PATCH',
|
||||
'HEAD',
|
||||
'OPTIONS',
|
||||
'CONNECT',
|
||||
'TRACE'
|
||||
]);
|
||||
|
||||
/**
|
||||
* HTTP 请求缓存信息
|
||||
*/
|
||||
@@ -117,11 +103,22 @@ const httpRequestsField = StateField.define<Map<number, CachedHttpRequest>>({
|
||||
* 运行按钮 Gutter Marker
|
||||
*/
|
||||
class RunButtonMarker extends GutterMarker {
|
||||
private isLoading = false;
|
||||
private buttonElement: HTMLButtonElement | null = null;
|
||||
private readonly debouncedExecute: ((view: EditorView) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly lineNumber: number,
|
||||
private readonly cachedRequest: HttpRequest
|
||||
) {
|
||||
super();
|
||||
|
||||
// 创建防抖执行函数
|
||||
const { debouncedFn } = createDebounce(
|
||||
(view: EditorView) => this.executeRequestInternal(view),
|
||||
{ delay: 500 }
|
||||
);
|
||||
this.debouncedExecute = debouncedFn;
|
||||
}
|
||||
|
||||
toDOM(view: EditorView) {
|
||||
@@ -130,26 +127,82 @@ class RunButtonMarker extends GutterMarker {
|
||||
button.innerHTML = '▶';
|
||||
button.title = 'Run HTTP Request';
|
||||
button.setAttribute('aria-label', 'Run HTTP Request');
|
||||
|
||||
this.buttonElement = button;
|
||||
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.executeRequest(view);
|
||||
};
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private async executeRequest(view: EditorView) {
|
||||
console.log(`\n============ 执行 HTTP 请求 ============`);
|
||||
console.log('行号:', this.lineNumber);
|
||||
private executeRequest(view: EditorView) {
|
||||
if (this.debouncedExecute) {
|
||||
this.debouncedExecute(view);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRequestInternal(view: EditorView) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
// 直接使用缓存的解析结果,无需重新解析!
|
||||
console.log('解析结果:', JSON.stringify(this.cachedRequest, null, 2));
|
||||
this.setLoadingState(true);
|
||||
|
||||
// TODO: 调用后端 API 执行请求
|
||||
// const response = await executeHttpRequest(this.cachedRequest);
|
||||
// renderResponse(response);
|
||||
try {
|
||||
console.log(`\n============ 执行 HTTP 请求 ============`);
|
||||
console.log('行号:', this.lineNumber);
|
||||
console.log('解析结果:', JSON.stringify(this.cachedRequest, null, 2));
|
||||
|
||||
// TODO: 调用后端 API 执行请求
|
||||
// 临时模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
|
||||
|
||||
// 临时模拟响应数据用于测试
|
||||
const mockResponse: HttpResponse = {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
time: Math.floor(Math.random() * 500) + 50, // 50-550ms
|
||||
body: {
|
||||
code: 200,
|
||||
message: "请求成功",
|
||||
data: {
|
||||
id: 1001,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
// 插入响应数据
|
||||
insertHttpResponse(view, this.cachedRequest.position.from, mockResponse);
|
||||
} catch (error) {
|
||||
console.error('HTTP 请求执行失败:', error);
|
||||
} finally {
|
||||
this.setLoadingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setLoadingState(loading: boolean) {
|
||||
this.isLoading = loading;
|
||||
|
||||
if (this.buttonElement) {
|
||||
if (loading) {
|
||||
this.buttonElement.className = 'cm-http-run-button cm-http-run-button-loading';
|
||||
this.buttonElement.title = 'Request in progress...';
|
||||
this.buttonElement.disabled = true;
|
||||
} else {
|
||||
this.buttonElement.className = 'cm-http-run-button';
|
||||
this.buttonElement.title = 'Run HTTP Request';
|
||||
this.buttonElement.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +247,7 @@ export const httpRunButtonTheme = EditorView.baseTheme({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
transition: 'color 0.15s ease',
|
||||
transition: 'color 0.15s ease, opacity 0.15s ease',
|
||||
},
|
||||
|
||||
// 悬停效果
|
||||
@@ -208,6 +261,18 @@ export const httpRunButtonTheme = EditorView.baseTheme({
|
||||
color: '#3d8b40',
|
||||
// backgroundColor: 'rgba(76, 175, 80, 0.2)',
|
||||
},
|
||||
|
||||
// 加载状态样式
|
||||
'.cm-http-run-button-loading': {
|
||||
color: '#999999 !important', // 灰色
|
||||
cursor: 'not-allowed !important',
|
||||
opacity: '0.6',
|
||||
},
|
||||
|
||||
// 禁用悬停效果当加载时
|
||||
'.cm-http-run-button-loading:hover': {
|
||||
color: '#999999 !important',
|
||||
},
|
||||
});
|
||||
|
||||
// 导出 StateField 供扩展系统使用
|
||||
|
||||
Reference in New Issue
Block a user