如何实现 Json 文件的语法提示

快应用使用 manifest.json 配置应用的基本信息。manifest.json 的属性字段多,配置时经常需要查阅官方文档。所以,希望实现语法提示功能,包括自动补全和 hover 查看属性信息,减少开发者查阅文档次数。本篇文章,就“如何实现 Json 文件的语法提示”,跟大家分享下。

manifest.json 增加语法提示

  • 最终效果
    我们先看看最终效果:

    如图所示,会自动提示属性和属性值,选中后可自动补全。

  • 新建插件
    快应用开发工具是基于 vscode 开发的,支持加载 vscode 插件。vscode 插件,可以通过配置 jsonValidation 字段,实现 json 文件的智能提示功能。

    首先,我们需要新建插件。如何新建插件,请参考 Your First Extension

  • 配置 package.json
    新建插件后,按文档配置 package.json 即可。具体如下:

    {
      "contributes": {
        "jsonValidation": [
          {
            "fileMatch": "manifest.json",
            "url": "./schemas-manifest.json"
          }
        ]
      }
    }
    

    fileMatch 用于指定匹配的文件,除了填写完整的文件名,也支持模糊匹配,比如 *icon-theme.json
    url 用于指定 jsonSchemas 文件,支持本地路径,也支持远程服务的 url,例如 https://json.schemastore.org/jshintrc,常用的 shcemas 可以从 JSON Schema Store 获取。

  • 编写 schemas-manifest.json
    schemas-manifest.json 的一部分配置如下:

    {
      // 定义必填字段
      "required": [
        "package",
        "name",
        "icon",
        "versionCode",
        "permissions",
        "config",
        "router"
      ],
      // 属性值
      "properties": {
        // package 字段:类型为 string,description 为该字段的描述信息,当鼠标放在该字段上可以查看描述信息。
        "package": {
          "type": "string",
          "description": "应用包名,确认与原生应用的包名不一致,推荐采用 com.company.module 的格式,如:com.example.demo"
        },
        // 支持多层嵌套,比如 router.pages,router.page.${pagePath}
        "router": {
          "type": "object",
          "description": "路由信息",
          "properties": {
            "pages": {
              "type": "object",
              "description": "页面配置列表,key 值为页面名称(对应页面目录名,例如 Hello 对应'Hello'目录),value 为页面详细配置 page",
              // pages 字段,是 object 类型,但 key 值是不确定的页面名称,所以不是设置 properties 字段,而是设置 patternProperties,对 key 值进行正则匹配。
              "patternProperties": {
                ".+": {
                  "type": "object",
                  "required": ["component"],
                  "properties": {
                    "component": {
                      "type": "string",
                      "description": "页面对应的组件名,与 ux 文件名保持一致,例如'hello' 对应 'hello.ux'"
                    }
                    // 略:其他属性
                  }
                }
              }
            }
            // 略:其他属性
          }
        }
      }
    }
    

    更多 json schemas 的配置方法,可以查看其官方文档

开发者自定义语法提示

除了开发插件,IDE 的用户也可以修改 IDE 中的配置,来增加 json 的语法提示。具体操作如下:

  • 打开 setting 面板
  • 搜索 json.schemas
  • 增加自定义配置,配置方法和 jsonValidation 相同,而 custom-schema.json 的编写方法,也和 schemas-manifest.json 相同。
    	"json.schemas": [
    		{
    			"fileMatch": ["custom.json"],
    			"url": "./custom-schema.json"
    		}
    	]
    

json schemas 是什么?

JSON Schema 本身是用 JSON 编写的,用于描述其他 JSON 文件的数据结构。
优点:跨语言,编写简单
缺点:不能对字段之间的关系进行限制,所以无法实现一些复杂的校验需求

vscode 如何整合 json schemas?

vscode 内置语法插件 json-language-features,用于提供 json 文件的语法提示。该语法插件分为 clientserver,两者通过 language server protocol 通信。由 server 解析文件并生成提示信息,发送给 client 显示。

client 端会遍历插件的 package.jsoncontributes.jsonValidation 字段,并发送给 server

client.onReady().then(() => {
  // client 发送消息
  client.sendNotification(
    SchemaAssociationNotification.type,
    getSchemaAssociations(context)
  );
});

// 获取 schema 配置
function getSchemaAssociations(
  _context: ExtensionContext
): ISchemaAssociation[] {
  const associations: ISchemaAssociation[] = [];
  extensions.all.forEach((extension) => {
    const packageJSON = extension.packageJSON;
    if (
      packageJSON &&
      packageJSON.contributes &&
      packageJSON.contributes.jsonValidation
    ) {
      const jsonValidation = packageJSON.contributes.jsonValidation;
      if (Array.isArray(jsonValidation)) {
        jsonValidation.forEach((jv) => {
          let { fileMatch, url } = jv;
          if (typeof fileMatch === "string") {
            fileMatch = [fileMatch];
          }
          if (Array.isArray(fileMatch) && typeof url === "string") {
            let uri: string = url;
            if (uri[0] === "." && uri[1] === "/") {
              uri = joinPath(extension.extensionUri, uri).toString();
            }
            fileMatch = fileMatch.map((fm) => {
              if (fm[0] === "%") {
                fm = fm.replace(/%APP_SETTINGS_HOME%/, "/User");
                fm = fm.replace(/%MACHINE_SETTINGS_HOME%/, "/Machine");
                fm = fm.replace(/%APP_WORKSPACES_HOME%/, "/Workspaces");
              } else if (!fm.match(/^(\w+:\/\/|\/|!)/)) {
                fm = "/" + fm;
              }
              return fm;
            });
            associations.push({ fileMatch, uri });
          }
        });
      }
    }
  });
  return associations;
}

server 接收到消息后,会执行 updateConfiguration() 更新配置,调用 languageService.configure(languageSettings),更新 languageService 中的 schema 配置。

// The jsonValidation extension configuration has changed
// 接收到消息
connection.onNotification(SchemaAssociationNotification.type, associations => {
	schemaAssociations = associations;
	updateConfiguration();
});

function updateConfiguration() {
	const languageSettings = {
		validate: true,
		allowComments: true,
		schemas: new Array<SchemaConfiguration>()
	};
	if (schemaAssociations) {
		if (Array.isArray(schemaAssociations)) {
			Array.prototype.push.apply(languageSettings.schemas, schemaAssociations);
		} else {
			for (const pattern in schemaAssociations) {
				const association = schemaAssociations[pattern];
				if (Array.isArray(association)) {
					association.forEach(uri => {
						languageSettings.schemas.push({ uri, fileMatch: [pattern] });
					});
				}
			}
		}
	}
	if (jsonConfigurationSettings) {
		jsonConfigurationSettings.forEach((schema, index) => {
			let uri = schema.url;
			if (!uri && schema.schema) {
				uri = schema.schema.id || `vscode://schemas/custom/${index}`;
			}
			if (uri) {
				languageSettings.schemas.push({ uri, fileMatch: schema.fileMatch, schema: schema.schema });
			}
		});
	}
	// languageService 更新配置
	languageService.configure(languageSettings);

	// Revalidate any open text documents
	documents.all().forEach(triggerValidation);
}

languageService 对应 vscode-json-languageservice,它提供了设置 shcema、校验、自动补全、hover 提示等接口给 server 调用。其具体实现比较繁琐,这里不再深入探究,有兴趣的可以阅读其源码。

export function getLanguageService(
  params: LanguageServiceParams
): LanguageService {
  const promise = params.promiseConstructor || Promise;

  const jsonSchemaService = new JSONSchemaService(
    params.schemaRequestService,
    params.workspaceContext,
    promise
  );
  jsonSchemaService.setSchemaContributions(schemaContributions);

  const jsonCompletion = new JSONCompletion(
    jsonSchemaService,
    params.contributions,
    promise,
    params.clientCapabilities
  );
  const jsonHover = new JSONHover(
    jsonSchemaService,
    params.contributions,
    promise
  );
  const jsonDocumentSymbols = new JSONDocumentSymbols(jsonSchemaService);
  const jsonValidation = new JSONValidation(jsonSchemaService, promise);

  return {
    // 设置 schema
    configure: (settings: LanguageSettings) => {
      jsonSchemaService.clearExternalSchemas();
      if (settings.schemas) {
        settings.schemas.forEach((settings) => {
          // 注册外部 shemas
          jsonSchemaService.registerExternalSchema(
            settings.uri,
            settings.fileMatch,
            settings.schema
          );
        });
      }
      jsonValidation.configure(settings);
    },
    resetSchema: (uri: string) => jsonSchemaService.onResourceChange(uri),
    // 校验
    doValidation: jsonValidation.doValidation.bind(jsonValidation),
    parseJSONDocument: (document: TextDocument) =>
      parseJSON(document, { collectComments: true }),
    newJSONDocument: (root: ASTNode, diagnostics: Diagnostic[]) =>
      newJSONDocument(root, diagnostics),
    getMatchingSchemas: jsonSchemaService.getMatchingSchemas.bind(
      jsonSchemaService
    ),
    doResolve: jsonCompletion.doResolve.bind(jsonCompletion),
    // 补全
    doComplete: jsonCompletion.doComplete.bind(jsonCompletion),
    findDocumentSymbols: jsonDocumentSymbols.findDocumentSymbols.bind(
      jsonDocumentSymbols
    ),
    findDocumentSymbols2: jsonDocumentSymbols.findDocumentSymbols2.bind(
      jsonDocumentSymbols
    ),
    findDocumentColors: jsonDocumentSymbols.findDocumentColors.bind(
      jsonDocumentSymbols
    ),
    getColorPresentations: jsonDocumentSymbols.getColorPresentations.bind(
      jsonDocumentSymbols
    ),
    // hover 提示
    doHover: jsonHover.doHover.bind(jsonHover),
    getFoldingRanges,
    getSelectionRanges,
    findDefinition: () => Promise.resolve([]),
    findLinks,
    format: (d, r, o) => {
      let range: JSONCRange | undefined = undefined;
      if (r) {
        const offset = d.offsetAt(r.start);
        const length = d.offsetAt(r.end) - offset;
        range = { offset, length };
      }
      const options = {
        tabSize: o ? o.tabSize : 4,
        insertSpaces: o?.insertSpaces === true,
        insertFinalNewline: o?.insertFinalNewline === true,
        eol: "\n",
      };
      return formatJSON(d.getText(), range, options).map((e) => {
        return TextEdit.replace(
          Range.create(
            d.positionAt(e.offset),
            d.positionAt(e.offset + e.length)
          ),
          e.content
        );
      });
    },
  };
}

tip

语法插件,经常分为 client 和 server 两端,主要是基于以下考虑:

  • 适配:server 端负责语法分析,只要求按照语法服务协议与客户端通信,可以用任何语言实现。一套代码,可以适配不同的 IDE。
  • 性能:语法分析通常会占用大量 CPU 和内存,在单独的进程中运行可以降低 IDE 的性能成本。语法分析出错时,也不会影响 IDE 的插件进程。

具体如何开发语法插件,可以查看vscode 文档