🚧 Added HTTP language parser

This commit is contained in:
2025-10-31 19:39:44 +08:00
parent 61a23fe7f2
commit 8ac78e39f1
18 changed files with 1200 additions and 13 deletions

View File

@@ -0,0 +1,31 @@
/**
* HTTP Client 扩展
*/
import {Extension} from '@codemirror/state';
import {httpRunButtonGutter, httpRunButtonTheme} from './widgets/run-gutter';
/**
* 创建 HTTP Client 扩展
*/
export function createHttpClientExtension(): Extension[] {
const extensions: Extension[] = [];
// HTTP 语言解析器
// extensions.push(httpLanguage);
// 运行按钮 Gutte
extensions.push(httpRunButtonGutter);
extensions.push(httpRunButtonTheme);
// TODO: 后续阶段添加
// - 自动补全(可选)
// - 变量支持(可选)
return extensions;
}

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* HTTP Grammar Parser Builder
* 编译 Lezer grammar 文件为 JavaScript parser
* 使用命令行方式编译
*/
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
console.log('🚀 开始编译 HTTP grammar parser...');
try {
// 检查语法文件是否存在
const grammarFile = path.join(__dirname, 'http.grammar');
if (!fs.existsSync(grammarFile)) {
throw new Error('语法文件 http.grammar 未找到');
}
console.log('📄 语法文件:', grammarFile);
// 运行 lezer-generator
console.log('⚙️ 编译 parser...');
execSync('npx lezer-generator http.grammar -o http.grammar.js', {
cwd: __dirname,
stdio: 'inherit'
});
// 检查生成的文件
const parserFile = path.join(__dirname, 'http.grammar.js');
const termsFile = path.join(__dirname, 'http.grammar.terms.js');
if (fs.existsSync(parserFile) && fs.existsSync(termsFile)) {
console.log('✅ Parser 文件成功生成!');
console.log('📦 生成的文件:');
console.log(' - http.grammar.js');
console.log(' - http.grammar.terms.js');
} else {
throw new Error('Parser 生成失败');
}
console.log('🎉 编译成功!');
} catch (error) {
console.error('❌ 编译失败:', error.message);
process.exit(1);
}

View File

@@ -0,0 +1,75 @@
import { styleTags, tags as t } from '@lezer/highlight';
/**
* HTTP Client 语法高亮配置
*/
export const httpHighlighting = styleTags({
// 注释
LineComment: t.lineComment,
// HTTP 方法关键字
"GET POST PUT DELETE PATCH HEAD OPTIONS": t.keyword,
// 关键字
"HEADER BODY VARIABLES RESPONSE": t.keyword,
// Body 类型
"TEXT JSON XML FORM URLENCODED GRAPHQL BINARY": t.keyword,
// 变量名
VariableName: t.variableName,
VariableValue: t.string,
// URL 和版本
Url: t.url,
UrlText: t.url,
HttpVersion: t.literal,
// Header
HeaderName: t.propertyName,
HeaderValue: t.string,
// Body 内容
BodyContent: t.content,
// Variables 内容
VariablesContent: t.content,
// 响应
StatusCode: t.number,
StatusText: t.string,
Duration: t.literal,
Size: t.literal,
Timestamp: t.literal,
// 文件引用
FilePath: t.string,
// 模板表达式
"{{ }}": t.special(t.brace),
"TemplateExpression/VariableName": t.variableName,
// 成员访问
"MemberExpression/VariableName": t.variableName,
"MemberExpression/PropertyName": t.propertyName,
PropertyName: t.propertyName,
// 函数调用
FunctionName: t.function(t.variableName),
// 基础类型
Number: t.number,
String: t.string,
// 符号
": Spread": t.punctuation,
"( )": t.paren,
"[ ]": t.squareBracket,
"{ }": t.brace,
".": t.derefOperator,
", ;": t.separator,
"@": t.meta,
"$": t.meta,
"=": t.definitionOperator,
});

View File

@@ -0,0 +1,130 @@
import { LRLanguage, LanguageSupport, foldNodeProp, foldInside, indentNodeProp } from '@codemirror/language';
import { parser } from './http.grammar.js';
import { httpHighlighting } from './http-highlight';
/**
* HTTP Client 语言定义
*/
// 配置折叠规则和高亮
const httpParserWithMetadata = parser.configure({
props: [
// 应用语法高亮
httpHighlighting,
// 折叠规则:允许折叠多行 Body、Variables、Headers 等
foldNodeProp.add({
BodyStatement: foldInside,
VariablesStatement: foldInside,
Document: foldInside,
}),
// 缩进规则
indentNodeProp.add({
BodyStatement: () => 2,
HeaderStatement: () => 0,
VariableDeclaration: () => 0,
}),
],
});
// 创建 LR 语言实例
export const httpLanguage = LRLanguage.define({
parser: httpParserWithMetadata,
languageData: {
// 注释配置
commentTokens: { line: '#' },
// 自动闭合括号
closeBrackets: { brackets: ['(', '[', '{', '"', "'"] },
// 单词字符定义
wordChars: '-_',
},
});
/**
* HTTP Client 语言支持
* 包含语法高亮、折叠、缩进等完整功能
*/
export function http() {
return new LanguageSupport(httpLanguage, [
httpLanguage.data.of({
autocomplete: httpCompletion,
}),
]);
}
/**
* HTTP Client 自动补全
*/
function httpCompletion(context: any) {
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: 'HEADER', type: 'keyword', detail: 'Header Statement' },
{ label: 'BODY', type: 'keyword', detail: 'Body Statement' },
{ label: 'VARIABLES', type: 'keyword', detail: 'Variables Statement' },
// Body 类型
{ label: 'TEXT', type: 'keyword', detail: 'Body Type' },
{ label: 'JSON', type: 'keyword', detail: 'Body Type' },
{ label: 'XML', type: 'keyword', detail: 'Body Type' },
{ label: 'FORM', type: 'keyword', detail: 'Body Type' },
{ label: 'URLENCODED', type: 'keyword', detail: 'Body Type' },
{ label: 'GRAPHQL', type: 'keyword', detail: 'Body Type' },
{ label: 'BINARY', type: 'keyword', detail: 'Body Type' },
// HTTP 版本
{ label: 'HTTP/1.0', type: 'constant', detail: 'HTTP Version' },
{ label: 'HTTP/1.1', type: 'constant', detail: 'HTTP Version' },
{ label: 'HTTP/2.0', type: 'constant', detail: 'HTTP Version' },
// 常用 Headers
{ label: 'Content-Type', type: 'property', detail: 'Header Name' },
{ label: 'Authorization', type: 'property', detail: 'Header Name' },
{ label: 'Accept', type: 'property', detail: 'Header Name' },
{ label: 'User-Agent', type: 'property', detail: 'Header Name' },
{ label: 'Cookie', type: 'property', detail: 'Header Name' },
// 常用 Content-Type
{ label: 'application/json', type: 'constant', detail: 'Content Type' },
{ label: 'application/xml', type: 'constant', detail: 'Content Type' },
{ label: 'text/html', 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: '@timestamp', type: 'keyword', detail: 'Timestamp' },
{ label: '@file', type: 'keyword', detail: 'File Reference' },
// 内置函数
{ label: '$timestamp()', type: 'function', detail: 'Current Timestamp' },
{ label: '$uuid()', type: 'function', detail: 'Generate UUID' },
{ label: '$randomInt()', type: 'function', detail: 'Random Integer' },
{ label: '$hash()', type: 'function', detail: 'Hash Function' },
],
};
}
/**
* 导出语言定义和高亮配置
*/
export { httpHighlighting };

View File

@@ -0,0 +1,267 @@
@precedence {
member,
call
}
@top Document { statement* }
statement {
VariableDeclaration |
ResponseLine |
RequestLine |
HeaderStatement |
BodyStatement |
VariablesStatement
}
// ==================== 变量定义 ====================
VariableDeclaration {
"@" VariableName "=" VariableValue ";"
}
VariableName { word }
VariableValue[isolate] { anyContent }
// ==================== 请求行 ====================
RequestLine {
Method Url HttpVersion? ";"
}
Method {
@specialize[@name="GET"]<word, "GET"> |
@specialize[@name="POST"]<word, "POST"> |
@specialize[@name="PUT"]<word, "PUT"> |
@specialize[@name="DELETE"]<word, "DELETE"> |
@specialize[@name="PATCH"]<word, "PATCH"> |
@specialize[@name="HEAD"]<word, "HEAD"> |
@specialize[@name="OPTIONS"]<word, "OPTIONS"> |
@specialize[@name="CONNECT"]<word, "CONNECT"> |
@specialize[@name="TRACE"]<word, "TRACE">
}
Url { urlPart+ }
urlPart { urlContent | TemplateExpression }
HttpVersion { httpVersionToken }
// ==================== Header 语句 ====================
HeaderStatement {
HeaderKeyword HeaderName colon HeaderValue ";"
}
colon { ":" }
HeaderKeyword { @specialize[@name="HEADER"]<word, "HEADER"> }
HeaderName { word }
HeaderValue { headerValuePart* }
headerValuePart { headerValueContent | TemplateExpression }
// ==================== Body 语句 ====================
BodyStatement {
BodyKeyword BodyType BodyContent ";"
}
BodyKeyword { @specialize[@name="BODY"]<word, "BODY"> }
BodyType {
@specialize[@name="JSON"]<word, "JSON"> |
@specialize[@name="FORM"]<word, "FORM"> |
@specialize[@name="URLENCODED"]<word, "URLENCODED"> |
@specialize[@name="GRAPHQL"]<word, "GRAPHQL"> |
@specialize[@name="XML"]<word, "XML"> |
@specialize[@name="TEXT"]<word, "TEXT"> |
@specialize[@name="BINARY"]<word, "BINARY"> |
@specialize[@name="MULTIPART"]<word, "MULTIPART">
}
BodyContent { bodyContentPart* }
bodyContentPart { bodyText | TemplateExpression | FileReference }
FileReference {
"@file" FilePath
}
FilePath { filePathContent }
// ==================== Variables 语句 ====================
VariablesStatement {
VariablesKeyword VariablesContent ";"
}
VariablesKeyword { @specialize[@name="VARIABLES"]<word, "VARIABLES"> }
VariablesContent[isolate] { variablesContent }
// ==================== 响应 ====================
// 响应行 - 固定格式RESPONSE <状态码> <状态文本> <大小> <时间戳>;
ResponseLine {
ResponseKeyword StatusCode StatusText Size Timestamp ";"
}
ResponseKeyword { @specialize[@name="RESPONSE"]<word, "RESPONSE"> }
StatusCode { Number }
StatusText { word+ }
Size { Number sizeUnit }
// 时间戳格式YYYY-MM-DD HH:MM:SS 或 ISO8601 格式
Timestamp { timestampContent }
// ==================== 模板表达式 ====================
TemplateExpression {
"{{" templateContent "}}"
}
templateContent {
VariableName |
MemberExpression |
FunctionCall
}
MemberExpression {
VariableName !member ("." PropertyName)+
}
PropertyName { word }
FunctionCall {
"$" FunctionName !call "(" argumentList? ")"
}
FunctionName { word }
argumentList {
argument ("," argument)*
}
argument { Number | String | word }
// ==================== Tokens ====================
@skip { spaces | newline | LineComment }
@tokens {
// 空白字符
spaces[@export] { $[ \t]+ }
newline[@export] { $[\r\n] }
// 注释
LineComment[@export,isolate] { "#" ![\n]* }
// 标识符
identifierChar { @asciiLetter | $[_$] }
word { identifierChar (identifierChar | @digit | $[-])* }
// 数字
Number {
@digit+ ("." @digit+)?
}
// 字符串
String {
'"' stringContentDouble* '"' |
"'" stringContentSingle* "'"
}
stringContentDouble { ![\\\n"]+ | "\\" _ }
stringContentSingle { ![\\\n']+ | "\\" _ }
// URL 内容 - 排除空格、换行、特殊前缀字符
// 允许 # (用于锚点),但不允许以 # 开头(避免和注释冲突)
urlContent { ![ \t\n\r@{};]+ }
// HTTP 版本
httpVersionToken { "HTTP/" @digit+ "." @digit+ }
// Header 值内容 - 排除所有 skip tokens 和特殊字符
headerValueContent { ![ \t\n\r#{};]+ }
// Body 文本内容 - 排除空格、换行、分号
// 允许大括号、#、@、$ 等因为JSON/XML/GraphQL/email等需要它们
// {{ 和 @file 是独立token有更高优先级
bodyText { ![ \t\n\r;]+ }
// 任意内容(直到分号) - 只排除换行和分号
// 允许空格、#、数字等所有字符
anyContent { ![\n\r;]+ }
// 文件路径内容 - 排除空格、换行、分号
filePathContent { ![ \t\n\r;]+ }
// Variables 内容 - 只排除分号,允许所有字符包括换行
variablesContent { ![;]+ }
// 时间戳内容 - 日期时间格式,包含日期时间字符
timestampContent { timestampChar+ }
timestampChar { @digit | $[-:TZ.+] }
// Duration 单位
durationUnit { "ms" | "s" | "m" }
// Size 单位
sizeUnit { "B" | "KB" | "MB" | "GB" }
// 优先级声明
@precedence { urlContent, LineComment }
@precedence { "{{", bodyText }
@precedence { "@file", bodyText }
@precedence { "$", bodyText }
@precedence { bodyText, LineComment }
@precedence { anyContent, LineComment }
@precedence { anyContent, spaces }
@precedence { filePathContent, LineComment }
@precedence { variablesContent, newline }
@precedence { variablesContent, LineComment }
@precedence { variablesContent, spaces }
@precedence { "$", word }
@precedence { spaces, newline, word }
@precedence { httpVersionToken, urlContent, word }
@precedence { "{{", bodyText }
@precedence { word, bodyText }
@precedence { Number, word }
// 符号
"(" ")" "[" "]" "{" "}"
"." "," ":" "=" "@" "$" ";" "{{" "}}"
// 组合 tokens
"@file"
}
@detectDelim

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
spaces = 78,
newline = 79,
LineComment = 1,
Document = 2,
VariableDeclaration = 4,
VariableName = 6,
VariableValue = 8,
ResponseLine = 9,
ResponseKeyword = 10,
RESPONSE = 11,
StatusCode = 12,
Number = 13,
StatusText = 14,
Size = 15,
Timestamp = 16,
RequestLine = 17,
Method = 18,
GET = 19,
POST = 20,
PUT = 21,
DELETE = 22,
PATCH = 23,
HEAD = 24,
OPTIONS = 25,
CONNECT = 26,
TRACE = 27,
Url = 28,
TemplateExpression = 31,
MemberExpression = 32,
PropertyName = 34,
FunctionCall = 37,
FunctionName = 38,
String = 40,
HttpVersion = 42,
HeaderStatement = 43,
HeaderKeyword = 44,
HEADER = 45,
HeaderName = 46,
HeaderValue = 48,
BodyStatement = 49,
BodyKeyword = 50,
BODY = 51,
BodyType = 52,
JSON = 53,
FORM = 54,
URLENCODED = 55,
GRAPHQL = 56,
XML = 57,
TEXT = 58,
BINARY = 59,
MULTIPART = 60,
BodyContent = 61,
FileReference = 62,
FilePath = 64,
VariablesStatement = 65,
VariablesKeyword = 66,
VARIABLES = 67,
VariablesContent = 68

View File

@@ -0,0 +1,3 @@
export { http, httpLanguage, httpHighlighting } from './http-language';
export { parser } from './http.grammar.js';

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,199 @@
import { EditorView, GutterMarker, gutter } from '@codemirror/view';
import { syntaxTree } from '@codemirror/language';
import { getNoteBlockFromPos } from '../../codeblock/state';
import type { SyntaxNode } from '@lezer/common';
// ==================== 常量定义 ====================
/** 支持的 HTTP 方法(小写) - 使用 Set 以提高查找性能 */
const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'connect', 'trace']);
/** 匹配 ### Request 标记的正则表达式 */
const REQUEST_MARKER_REGEX = /^###\s+Request(?:\s|$)/i;
/** 匹配 ### Response 标记的正则表达式 */
const RESPONSE_MARKER_REGEX = /^###\s+Response/i;
/** 匹配 HTTP 方法的正则表达式 */
const HTTP_METHOD_REGEX = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+/i;
/** HTTP 方法在行首的最大偏移位置(字符数) */
const MAX_METHOD_POSITION_OFFSET = 20;
/** 向上查找 ### Request 标记的最大行数 */
const MAX_REQUEST_MARKER_DISTANCE = 10;
// ==================== 运行按钮 Marker ====================
/**
* 运行按钮 Gutter Marker
*/
class RunButtonMarker extends GutterMarker {
constructor(private readonly linePosition: number) {
super();
}
toDOM(view: EditorView) {
const button = document.createElement('button');
button.className = 'cm-http-run-button';
button.innerHTML = '▶';
button.title = 'Run HTTP Request';
button.setAttribute('aria-label', 'Run HTTP Request');
button.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
this.executeRequest(view);
};
return button;
}
private async executeRequest(view: EditorView) {
console.log(`\n============ 执行 HTTP 请求 ============`);
console.log(`位置: ${this.linePosition}`);
}
}
/**
* 使用语法树检查一行是否是 HTTP 请求行(更可靠)
* 必须符合规则:前面有 ### Request然后才是 GET/POST 等请求行
*/
function isRequestLineInSyntaxTree(view: EditorView, lineFrom: number, lineTo: number): boolean {
const tree = syntaxTree(view.state);
let hasHttpMethod = false;
// 遍历该行的语法树节点
tree.iterate({
from: lineFrom,
to: lineTo,
enter: (node: SyntaxNode) => {
// HTTP 解析器将 HTTP 方法GET、POST 等)标记为 "keyword"
// 并且该节点应该在行首附近
if (node.name === 'keyword' &&
node.from >= lineFrom &&
node.from < lineFrom + MAX_METHOD_POSITION_OFFSET) {
const text = view.state.sliceDoc(node.from, node.to);
if (HTTP_METHODS.has(text.toLowerCase())) {
// 检查前面是否有 ### Request 标记
if (hasPrecedingRequestMarker(view, lineFrom)) {
hasHttpMethod = true;
}
}
}
}
});
return hasHttpMethod;
}
/**
* 检查前面是否有 ### Request 标记
* 只要包含 "### Request",后面可以跟任何描述文字
*/
function hasPrecedingRequestMarker(view: EditorView, lineFrom: number): boolean {
const currentLineNum = view.state.doc.lineAt(lineFrom).number;
// 向上查找前面的几行(最多往上找指定行数)
for (let i = currentLineNum - 1;
i >= Math.max(1, currentLineNum - MAX_REQUEST_MARKER_DISTANCE);
i--) {
const line = view.state.doc.line(i);
const lineText = view.state.sliceDoc(line.from, line.to).trim();
if (REQUEST_MARKER_REGEX.test(lineText)) {
return true;
}
// 如果遇到 ### Response停止查找
if (RESPONSE_MARKER_REGEX.test(lineText)) {
return false;
}
// 如果是空行,继续往上找
if (lineText === '') {
continue;
}
// 如果遇到另一个请求方法,停止查找
if (HTTP_METHOD_REGEX.test(lineText)) {
return false;
}
}
return false;
}
/**
* 检查位置是否在 HTTP 块内
*/
function isInHttpBlock(view: EditorView, pos: number): boolean {
try {
const block = getNoteBlockFromPos(view.state, pos);
return block?.language.name === 'http' || block?.language.name === 'rest';
} catch {
return false;
}
}
/**
* 创建运行按钮 Gutter
*/
export const httpRunButtonGutter = gutter({
class: 'cm-http-gutter',
// 为每一行决定是否显示 marker
lineMarker(view, line) {
const linePos = line.from;
// 第一步:检查是否在 HTTP 块内
if (!isInHttpBlock(view, linePos)) {
return null;
}
// 第二步:使用语法树检查是否是请求行
if (!isRequestLineInSyntaxTree(view, line.from, line.to)) {
return null;
}
// 创建运行按钮
return new RunButtonMarker(linePos);
},
});
export const httpRunButtonTheme = EditorView.baseTheme({
// 运行按钮样式
'.cm-http-run-button': {
// width: '18px',
// height: '18px',
border: 'none',
borderRadius: '2px',
backgroundColor: 'transparent',
color: '#4CAF50', // 绿色三角
// fontSize: '13px',
// lineHeight: '16px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
transition: 'color 0.15s ease',
},
// 悬停效果
'.cm-http-run-button:hover': {
color: '#45a049', // 深绿色
// backgroundColor: 'rgba(76, 175, 80, 0.1)', // 淡绿色背景
},
// 激活效果
'.cm-http-run-button:active': {
color: '#3d8b40',
// backgroundColor: 'rgba(76, 175, 80, 0.2)',
},
});