Browse Source

Editor: validation/completion for General/Host sections

bdbai 3 years ago
parent
commit
67b618bf71

+ 16 - 0
Maple.App/MonacoEditor/package-lock.json

@@ -9,6 +9,7 @@
       "version": "0.1.0",
       "license": "Apache-2.0",
       "devDependencies": {
+        "ipaddr.js": "^2.0.1",
         "monaco-editor": "^0.32.1",
         "parcel": "^2.7.0"
       }
@@ -1933,6 +1934,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/ipaddr.js": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
+      "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==",
+      "dev": true,
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -3901,6 +3911,12 @@
         "resolve-from": "^4.0.0"
       }
     },
+    "ipaddr.js": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
+      "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==",
+      "dev": true
+    },
     "is-arrayish": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",

+ 1 - 0
Maple.App/MonacoEditor/package.json

@@ -20,6 +20,7 @@
   },
   "homepage": "https://github.com/YtFlow/Maple#readme",
   "devDependencies": {
+    "ipaddr.js": "^2.0.1",
     "monaco-editor": "^0.32.1",
     "parcel": "^2.7.0"
   },

+ 1 - 0
Maple.App/MonacoEditor/src/facts.ts

@@ -59,3 +59,4 @@ export const GENERAL_SETTING_KEYS: IGeneralSettingDef[] = [
 export const GENERAL_SETTINGS_KEYS_SET = new Set(GENERAL_SETTING_KEYS.map(k => k.name))
 
 export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error']
+export const LOG_LEVELS_SET = new Set(LOG_LEVELS)

+ 24 - 7
Maple.App/MonacoEditor/src/parse.ts

@@ -12,7 +12,7 @@ export interface ILeafConfStruct {
     sections: ILeafConfSection[]
 }
 
-export interface ILeafConfGeneralItem {
+export interface ILeafConfKvItem {
     lineId: number,
     key: string,
     keyStartCol: number,
@@ -20,6 +20,8 @@ export interface ILeafConfGeneralItem {
     valueStartCol: number,
 }
 
+export type ILeafConfTextSpan = { text: string, startCol: number }
+
 export function trimWithPos(s: string, startCol: number) {
     const matches = s.match(/^(\s*)([^\s].*)$/)
     if (!matches) {
@@ -37,6 +39,21 @@ export function trimComment(s: string): string {
     return s.slice(0, commentPos)
 }
 
+export function splitByComma(s: string, startCol: number): ILeafConfTextSpan[] {
+    const ret: ILeafConfTextSpan[] = []
+    let commaPos = s.indexOf(',')
+    while (commaPos !== -1) {
+        const trimmed = trimWithPos(s.slice(0, commaPos), startCol)
+        ret.push({ text: trimmed.trimmed, startCol: trimmed.startCol })
+        s = s.slice(commaPos + 1)
+        startCol += commaPos + 1
+        commaPos = s.indexOf(',')
+    }
+    const trimmed = trimWithPos(s, startCol)
+    ret.push({ text: trimmed.trimmed, startCol: trimmed.startCol })
+    return ret
+}
+
 export function parseStruct(model: monaco.editor.ITextModel): ILeafConfStruct {
     const ret: ILeafConfStruct = { sections: [] }
     const sectionHeaderMatches = model.findMatches(`^(\\s*)\\[([^\\]#]+)\\]`, true, true, true, null, true)
@@ -66,8 +83,8 @@ export function parseStruct(model: monaco.editor.ITextModel): ILeafConfStruct {
         lastSection.endLine = lastSectionLastLineId
         ret.sections.push(lastSection)
         const trimmedSectionName = trimWithPos(
-            firstSectionHeaderMatch.matches?.[2] || '',
-            (firstSectionHeaderMatch.matches?.[1].length ?? 0) + 2,
+            sectionHeaderMatch.matches?.[2] || '',
+            (sectionHeaderMatch.matches?.[1].length ?? 0) + 2,
         )
         sectionName = trimmedSectionName.trimmed
         sectionNameStartCol = trimmedSectionName.startCol
@@ -92,7 +109,7 @@ export function findIndexOfSections(sections: ILeafConfSection[], lineId: number
     return sections.length - pos - 1
 }
 
-export function parseGeneralLine(s: string, lineId: number, startCol: number): ILeafConfGeneralItem | undefined {
+export function parseKvLine(s: string, lineId: number, startCol: number): ILeafConfKvItem | undefined {
     s = trimComment(s)
     const eqPos = s.indexOf('=')
     if (eqPos === -1) {
@@ -107,14 +124,14 @@ export function parseGeneralLine(s: string, lineId: number, startCol: number): I
 export function parseSectionGeneral(
     model: monaco.editor.ITextModel,
     struct: ILeafConfStruct,
-): ILeafConfGeneralItem[] {
-    let ret: ILeafConfGeneralItem[] = []
+): ILeafConfKvItem[] {
+    let ret: ILeafConfKvItem[] = []
     for (const lineId of struct.sections
         .filter(s => s.sectionName === SECTION_GENERAL)
         .flatMap(s => Array.from({ length: s.endLine - s.startLine }, (_, i) => i + s.startLine + 1))) {
 
         const line = model.getLineContent(lineId)
-        const item = parseGeneralLine(line, lineId, 1)
+        const item = parseKvLine(line, lineId, 1)
         if (item !== undefined) {
             ret.push(item)
         }

+ 276 - 9
Maple.App/MonacoEditor/src/validate.ts

@@ -1,10 +1,276 @@
 import * as monaco from 'monaco-editor'
-import { KNOWN_SECTION_NAMES, KNOWN_SECTION_NAMES_SET } from './facts'
-import { parseStruct, trimComment, trimWithPos } from './parse'
+import * as facts from './facts'
+import {
+    ILeafConfKvItem,
+    ILeafConfStruct,
+    parseKvLine,
+    parseStruct,
+    splitByComma,
+    trimComment,
+    trimWithPos,
+} from './parse'
+import { isValid as isValidIpAddr } from 'ipaddr.js'
+
+function validateGeneral(
+    model: monaco.editor.ITextModel,
+    struct: ILeafConfStruct,
+    errors: monaco.editor.IMarkerData[],
+) {
+    const visitedKeyItem: Map<string, ILeafConfKvItem> = new Map()
+    let fakeIpFilterMode = ''
+    for (const section of struct.sections.filter(s => s.sectionName === facts.SECTION_GENERAL)) {
+        let currLineId = section.startLine
+        while (++currLineId <= section.endLine) {
+            const { trimmed: line, startCol } = trimWithPos(trimComment(model.getLineContent(currLineId)), 1)
+            if (line.length === 0) {
+                continue
+            }
+            const item = parseKvLine(line, currLineId, startCol)
+            if (item === undefined) {
+                errors.push({
+                    severity: monaco.MarkerSeverity.Error,
+                    startLineNumber: currLineId,
+                    startColumn: startCol,
+                    endLineNumber: currLineId,
+                    endColumn: startCol + line.length,
+                    message: `Expected "=".`,
+                })
+                continue
+            }
+
+            const firstVisitedSameKeyItem = visitedKeyItem.get(item.key)
+            if (firstVisitedSameKeyItem === undefined) {
+                visitedKeyItem.set(item.key, item)
+            } else {
+                errors.push({
+                    severity: monaco.MarkerSeverity.Error,
+                    startLineNumber: item.lineId,
+                    startColumn: item.keyStartCol,
+                    endLineNumber: item.lineId,
+                    endColumn: item.keyStartCol + item.key.length,
+                    message: `Duplicate setting "${item.key}".`,
+                    relatedInformation: [{
+                        startLineNumber: firstVisitedSameKeyItem.lineId,
+                        startColumn: firstVisitedSameKeyItem.keyStartCol,
+                        endLineNumber: firstVisitedSameKeyItem.lineId,
+                        endColumn: firstVisitedSameKeyItem.keyStartCol + firstVisitedSameKeyItem.key.length,
+                        message: `First definition of "${item.key}" is here.`,
+                        resource: model.uri,
+                    }],
+                })
+            }
+
+            switch (item.key) {
+                case facts.SETTING_TUN_FD:
+                    if (Number.isNaN(Number.parseInt)) {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `tun-fd must be a number.`,
+                        })
+                    }
+                    errors.push({
+                        severity: monaco.MarkerSeverity.Info,
+                        startLineNumber: currLineId,
+                        startColumn: item.valueStartCol,
+                        endLineNumber: currLineId,
+                        endColumn: item.valueStartCol + item.value.length,
+                        message: `tun-fd is only a dummy option for Leaf core to enable TUN inbound on UWP VPN Platform. "tun = auto" has the same effect with better semantics.`,
+                    })
+                    break
+                case facts.SETTING_TUN:
+                    if (item.value !== 'auto') {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Warning,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Any value for "tun" except "auto" has no effect in Maple.`,
+                        })
+                    }
+                    break
+                case facts.SETTING_LOGLEVEL:
+                    if (!facts.LOG_LEVELS_SET.has(item.value)) {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Invalid log level. Valid values are "${facts.LOG_LEVELS.join('", "')}".`,
+                        })
+                    }
+                    break
+                case facts.SETTING_LOGOUTPUT:
+                    break
+                case facts.SETTING_DNS_SERVER:
+                    errors.push(...splitByComma(item.value, item.valueStartCol)
+                        .filter(s => !isValidIpAddr(s.text))
+                        .map(s => ({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: s.startCol,
+                            endLineNumber: currLineId,
+                            endColumn: s.startCol + s.text.length,
+                            message: `Invalid IP address.`,
+                        })))
+                    break
+                case facts.SETTING_ALWAYS_REAL_IP:
+                    if (fakeIpFilterMode === 'fake') {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Cannot set "always-real-ip" when "always-fake-ip" is present.`,
+                        })
+                    } else {
+                        fakeIpFilterMode = 'real'
+                    }
+                    break
+                case facts.SETTING_ALWAYS_FAKE_IP:
+                    if (fakeIpFilterMode === 'real') {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.keyStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Cannot set "always-fake-ip" when "always-real-ip" is present.`,
+                        })
+                    } else {
+                        fakeIpFilterMode = 'fake'
+                    }
+                    break
+                case facts.SETTING_ROUTING_DOMAIN_RESOLVE:
+                    if (item.value !== 'true' && item.value !== 'false') {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.keyStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `"true" or "false" expected.`,
+                        })
+                    }
+                    break
+                case facts.SETTING_DNS_INTERFACE:
+                case facts.SETTING_HTTP_INTERFACE:
+                case facts.SETTING_INTERFACE:
+                case facts.SETTING_SOCKS_INTERFACE:
+                case facts.SETTING_API_INTERFACE:
+                    break
+                case facts.SETTING_HTTP_PORT:
+                case facts.SETTING_PORT:
+                case facts.SETTING_SOCKS_PORT:
+                case facts.SETTING_API_PORT:
+                    const portNumber = Number.parseInt(item.value)
+                    if (Number.isNaN(portNumber)) {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Invalid port number.`,
+                        })
+                    }
+                    if (portNumber < 0 || portNumber > 65535) {
+                        errors.push({
+                            severity: monaco.MarkerSeverity.Error,
+                            startLineNumber: currLineId,
+                            startColumn: item.valueStartCol,
+                            endLineNumber: currLineId,
+                            endColumn: item.valueStartCol + item.value.length,
+                            message: `Port number must be in range 0-65535.`,
+                        })
+                    }
+                    break
+                default:
+                    errors.push({
+                        severity: monaco.MarkerSeverity.Error,
+                        startLineNumber: currLineId,
+                        startColumn: item.keyStartCol,
+                        endLineNumber: currLineId,
+                        endColumn: item.keyStartCol + item.key.length,
+                        message: `Unknown setting entry "${item.key}".`,
+                    })
+            }
+        }
+    }
+}
+
+function validateHost(
+    model: monaco.editor.ITextModel,
+    struct: ILeafConfStruct,
+    errors: monaco.editor.IMarkerData[],
+) {
+    const visitedDomain: Map<string, ILeafConfKvItem> = new Map()
+    for (const section of struct.sections.filter(s => s.sectionName === facts.SECTION_HOST)) {
+        let currLineId = section.startLine
+        while (++currLineId <= section.endLine) {
+            const { trimmed: line, startCol } = trimWithPos(trimComment(model.getLineContent(currLineId)), 1)
+            if (line.length === 0) {
+                continue
+            }
+            const item = parseKvLine(line, currLineId, startCol)
+            if (item === undefined) {
+                errors.push({
+                    severity: monaco.MarkerSeverity.Error,
+                    startLineNumber: currLineId,
+                    startColumn: startCol,
+                    endLineNumber: currLineId,
+                    endColumn: startCol + line.length,
+                    message: `Expected "=".`,
+                })
+                continue
+            }
+
+            const firstVisitedSameDomainItem = visitedDomain.get(item.key)
+            if (firstVisitedSameDomainItem === undefined) {
+                visitedDomain.set(item.key, item)
+            } else {
+                errors.push({
+                    severity: monaco.MarkerSeverity.Error,
+                    startLineNumber: item.lineId,
+                    startColumn: item.keyStartCol,
+                    endLineNumber: item.lineId,
+                    endColumn: item.keyStartCol + item.key.length,
+                    message: `Duplicate host name "${item.key}".`,
+                    relatedInformation: [{
+                        startLineNumber: firstVisitedSameDomainItem.lineId,
+                        startColumn: firstVisitedSameDomainItem.keyStartCol,
+                        endLineNumber: firstVisitedSameDomainItem.lineId,
+                        endColumn: firstVisitedSameDomainItem.keyStartCol + firstVisitedSameDomainItem.key.length,
+                        message: `First definition of "${item.key}" is here.`,
+                        resource: model.uri,
+                    }],
+                })
+            }
+
+            errors.push(...splitByComma(item.value, item.valueStartCol)
+                .filter(s => !isValidIpAddr(s.text))
+                .map(s => ({
+                    severity: monaco.MarkerSeverity.Error,
+                    startLineNumber: currLineId,
+                    startColumn: s.startCol,
+                    endLineNumber: currLineId,
+                    endColumn: s.startCol + s.text.length,
+                    message: `Invalid IP address.`,
+                })))
+        }
+    }
+}
 
 export function validateModel(model: monaco.editor.ITextModel) {
     const lineCount = model.getLineCount()
-    const { sections } = parseStruct(model)
+    const struct = parseStruct(model)
+    const { sections } = struct
     const errors: monaco.editor.IMarkerData[] = []
     const firstSectionLineId = sections?.[0].startLine ?? lineCount + 1
     let currLineId = 1
@@ -13,7 +279,7 @@ export function validateModel(model: monaco.editor.ITextModel) {
         if (trimmed.length > 0) {
             errors.push({
                 severity: monaco.MarkerSeverity.Error,
-                message: `Content does not belong to any section`,
+                message: `Content does not belong to any section.`,
                 startLineNumber: currLineId,
                 startColumn: startCol,
                 endLineNumber: currLineId,
@@ -30,7 +296,7 @@ export function validateModel(model: monaco.editor.ITextModel) {
                 trimWithPos(sectionHeader.substring(sectionNameEndMarkPos), sectionNameEndMarkPos + 1)
             errors.push({
                 severity: monaco.MarkerSeverity.Error,
-                message: `Unexpected content after section header`,
+                message: `Unexpected content after section header.`,
                 startLineNumber: section.startLine,
                 startColumn: extraContentStartCol,
                 endLineNumber: section.startLine,
@@ -38,11 +304,11 @@ export function validateModel(model: monaco.editor.ITextModel) {
             })
         }
 
-        if (!KNOWN_SECTION_NAMES_SET.has(section.sectionName)) {
+        if (!facts.KNOWN_SECTION_NAMES_SET.has(section.sectionName)) {
             errors.push({
                 severity: monaco.MarkerSeverity.Error,
-                message: `Unknown section name: ${section.sectionName}
-Section names can only be one of: ${KNOWN_SECTION_NAMES.join(', ')}`,
+                message: `Unknown section name: ${section.sectionName}.
+Section names can only be one of: ${facts.KNOWN_SECTION_NAMES.join(', ')}`,
                 startLineNumber: section.startLine,
                 startColumn: section.sectionNameStartCol,
                 endLineNumber: section.startLine,
@@ -51,6 +317,7 @@ Section names can only be one of: ${KNOWN_SECTION_NAMES.join(', ')}`,
         }
         currLineId = section.startLine + 1
     }
-    // const [doc, errors] = parseModel(model)
+    validateGeneral(model, struct, errors)
+    validateHost(model, struct, errors)
     monaco.editor.setModelMarkers(model, 'Maple', errors)
 }