🚧 Improve HTTP language parser

This commit is contained in:
2025-11-01 21:40:51 +08:00
parent 93c85b800b
commit 94306497a9
9 changed files with 562 additions and 170 deletions

View File

@@ -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[];
}

View File

@@ -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 };

View File

@@ -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"

View File

@@ -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,
})

View File

@@ -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

View File

@@ -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
})

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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) {
@@ -131,25 +128,81 @@ class RunButtonMarker extends GutterMarker {
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) {
private executeRequest(view: EditorView) {
if (this.debouncedExecute) {
this.debouncedExecute(view);
}
}
private async executeRequestInternal(view: EditorView) {
if (this.isLoading) return;
this.setLoadingState(true);
try {
console.log(`\n============ 执行 HTTP 请求 ============`);
console.log('行号:', this.lineNumber);
// 直接使用缓存的解析结果,无需重新解析!
console.log('解析结果:', JSON.stringify(this.cachedRequest, null, 2));
// TODO: 调用后端 API 执行请求
// const response = await executeHttpRequest(this.cachedRequest);
// renderResponse(response);
// 临时模拟网络延迟
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 供扩展系统使用