From 94306497a91d03e9a55a9e8df5f608b9fbcbc293 Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sat, 1 Nov 2025 21:40:51 +0800 Subject: [PATCH] :construction: Improve HTTP language parser --- .../editor/extensions/httpclient/index.ts | 26 +- .../httpclient/language/http-language.ts | 63 +--- .../httpclient/language/http.grammar | 91 ++++- .../httpclient/language/http.highlight.ts | 23 +- .../httpclient/language/http.parser.terms.ts | 24 +- .../httpclient/language/http.parser.ts | 26 +- .../extensions/httpclient/language/index.ts | 27 +- .../httpclient/parser/response-inserter.ts | 337 ++++++++++++++++++ .../httpclient/widgets/run-gutter.ts | 115 ++++-- 9 files changed, 562 insertions(+), 170 deletions(-) create mode 100644 frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts diff --git a/frontend/src/views/editor/extensions/httpclient/index.ts b/frontend/src/views/editor/extensions/httpclient/index.ts index 307822d..35dd1a5 100644 --- a/frontend/src/views/editor/extensions/httpclient/index.ts +++ b/frontend/src/views/editor/extensions/httpclient/index.ts @@ -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[]; } diff --git a/frontend/src/views/editor/extensions/httpclient/language/http-language.ts b/frontend/src/views/editor/extensions/httpclient/language/http-language.ts index d913b26..a3a7fff 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/http-language.ts +++ b/frontend/src/views/editor/extensions/httpclient/language/http-language.ts @@ -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 }; diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.grammar b/frontend/src/views/editor/extensions/httpclient/language/http.grammar index 7a89106..5e15a59 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/http.grammar +++ b/frontend/src/views/editor/extensions/httpclient/language/http.grammar @@ -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] - 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] } +JsonFalse { @specialize[@name=False] } +JsonNull { @specialize[@name=Null] } + // Tokens @tokens { // 单行注释(# 开头到行尾) @@ -207,6 +270,8 @@ jsonValue { ":" "," "{" "}" + + "[" "]" } @external propSource httpHighlighting from "./http.highlight" diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts b/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts index 037d9d3..c16989c 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts +++ b/frontend/src/views/editor/extensions/httpclient/language/http.highlight.ts @@ -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, }) diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts b/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts index a1c3321..59cff4d 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts +++ b/frontend/src/views/editor/extensions/httpclient/language/http.parser.terms.ts @@ -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 diff --git a/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts b/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts index 9a536e1..be20ddc 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts +++ b/frontend/src/views/editor/extensions/httpclient/language/http.parser.ts @@ -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 }) diff --git a/frontend/src/views/editor/extensions/httpclient/language/index.ts b/frontend/src/views/editor/extensions/httpclient/language/index.ts index ccb9355..056ca06 100644 --- a/frontend/src/views/editor/extensions/httpclient/language/index.ts +++ b/frontend/src/views/editor/extensions/httpclient/language/index.ts @@ -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'; diff --git a/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts b/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts new file mode 100644 index 0000000..61f5f79 --- /dev/null +++ b/frontend/src/views/editor/extensions/httpclient/parser/response-inserter.ts @@ -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); +} + diff --git a/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts b/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts index 7c20624..4916c3c 100644 --- a/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts +++ b/frontend/src/views/editor/extensions/httpclient/widgets/run-gutter.ts @@ -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>({ * 运行按钮 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 供扩展系统使用