Initial commit of the Aperture VS Code extension, including core functionality for OpenAPI JSON/YAML IntelliSense, custom linting rules, and comprehensive test scenarios. Added configuration files, package dependencies, and example OpenAPI documents.

This commit is contained in:
Luke Hagar
2025-09-25 01:38:44 +00:00
parent 91dbd9994e
commit 639477015d
31 changed files with 7770 additions and 14 deletions

30
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${workspaceFolder}/npm: build"
},
{
"name": "Extension Tests",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/dist/test/suite/index"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "${workspaceFolder}/npm: build"
}
]
}

185
README.md
View File

@@ -1,15 +1,186 @@
# aperture # Aperture - OpenAPI IntelliSense Extension
To install dependencies: A VS Code extension that provides **schema-driven IntelliSense** (hover, validation, completions) for OpenAPI **JSON and YAML** files with **custom linting rules**.
```bash ## Features
bun install
### ✅ **Schema-Driven IntelliSense**
- **Hover support** with rich descriptions from OpenAPI schemas
- **Auto-completion** for OpenAPI properties and values
- **Schema validation** with precise error reporting
- **Multi-version support** (OpenAPI 3.2, 3.1, 3.0, Swagger 2.0)
### ✅ **Custom Rule Engine**
- **Root validation**: Ensures required `info` section
- **Schema naming**: Enforces PascalCase for schema names
- **Operation ID validation**: Prevents duplicate operation IDs
- **Path parameter validation**: Ensures path parameters are declared
- **Precise diagnostics**: Errors highlight exact offending nodes
### ✅ **Multi-File Awareness**
- **$ref resolution**: Follows references across multiple files
- **Cross-document validation**: Validates entire reference graphs
- **File watching**: Re-validates when referenced files change
- **Circular reference detection**: Prevents infinite loops
### ✅ **Performance Optimized**
- **Debounced validation**: Configurable delay (default 300ms)
- **File size limits**: Configurable maximum file size (default 5MB)
- **Intelligent caching**: Reduces redundant processing
- **Memory management**: Automatic cache cleanup
### ✅ **Settings & Configuration**
- **Version preference**: Auto-detect or force specific OpenAPI version
- **Type profile checking**: Optional TypeScript-based validation
- **Custom rules**: Enable/disable specific validation rules
- **Performance tuning**: Adjust debounce delay and file size limits
## Installation
1. Download the `.vsix` file from the releases
2. In VS Code, go to Extensions → Install from VSIX
3. Select the `aperture-0.0.1.vsix` file
4. Reload VS Code
## Usage
### Basic Usage
1. Open any OpenAPI YAML or JSON file
2. The extension automatically detects the OpenAPI version
3. IntelliSense and validation work immediately
4. Check the Problems panel for validation errors
### Settings Configuration
Open VS Code settings and search for "Aperture":
```json
{
"openapiLsp.versionPreference": "auto", // "auto" | "3.2" | "3.1" | "3.0" | "2.0"
"openapiLsp.enableTypeProfile": false, // Enable TypeScript type checking
"openapiLsp.enableCustomRules": true, // Enable custom validation rules
"openapiLsp.maxFileSize": 5, // Maximum file size in MB
"openapiLsp.debounceDelay": 300 // Validation delay in milliseconds
}
``` ```
To run: ### Supported File Types
- `.yaml` / `.yml` files
- `.json` files
- OpenAPI 3.2, 3.1, 3.0 specifications
- Swagger 2.0 specifications
## Architecture
### **Client-Server Architecture**
- **Client**: VS Code extension host using `vscode-languageclient`
- **Server**: LSP server with custom OpenAPI validation
- **Communication**: IPC-based language server protocol
### **Core Components**
- **Schema Loader**: Reads schemas from external types package
- **Ref Graph Builder**: Resolves `$ref` references across files
- **Custom Rule Engine**: Implements OpenAPI-specific validation rules
- **Range Mapper**: Maps JSON pointers to precise document ranges
- **Performance Manager**: Handles caching and debouncing
### **External Dependencies**
- **@your/openapi-types**: External package providing schemas and types
- **vscode-json-languageservice**: JSON schema validation
- **yaml-language-server**: YAML parsing and validation
- **jsonc-parser**: JSON with comments support
## Development
### Building the Extension
```bash ```bash
bun run index.ts npm install
npm run build
``` ```
This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. ### Packaging
```bash
npm run package
```
### Development Mode
1. Open the project in VS Code
2. Press F5 to launch Extension Development Host
3. Test with the example files in `/examples`
## Test Scenarios
The extension includes comprehensive test scenarios:
### Valid Examples
- `examples/openapi-3.2.yaml` - OpenAPI 3.2 specification
- `examples/openapi-3.1.yaml` - OpenAPI 3.1 specification
- `examples/swagger-2.0.yaml` - Swagger 2.0 specification
### Invalid Examples (for testing validation)
- `examples/invalid-missing-info.yaml` - Missing info section
- `examples/invalid-schema-naming.yaml` - Incorrect schema naming
- `examples/invalid-duplicate-operation-id.yaml` - Duplicate operation IDs
- `examples/invalid-path-parameters.yaml` - Undeclared path parameters
## Custom Rules
### Root Info Rule
- **Rule**: `root-info`
- **Description**: Root object must contain info section
- **Severity**: Error
### Schema Naming Rule
- **Rule**: `schema-naming`
- **Description**: Schema names should be PascalCase
- **Severity**: Warning
### Operation ID Rule
- **Rule**: `operation-id`
- **Description**: Operations should have unique operationId
- **Severity**: Warning
### Path Parameter Rule
- **Rule**: `path-parameter`
- **Description**: Path parameters must be declared in the path
- **Severity**: Warning
## Performance Features
### Caching
- **Document cache**: Caches parsed documents with version tracking
- **Schema cache**: Caches loaded schemas for reuse
- **Range cache**: Caches pointer-to-range mappings
### Debouncing
- **Configurable delay**: Default 300ms, adjustable via settings
- **Per-document timeouts**: Each document has its own debounce timer
- **Automatic cleanup**: Timeouts are cleared when documents close
### File Size Management
- **Size limits**: Configurable maximum file size (default 5MB)
- **Graceful degradation**: Large files show warning instead of crashing
- **Memory protection**: Prevents excessive memory usage
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Submit a pull request
## License
MIT License - see LICENSE file for details
## Changelog
### v0.0.1
- Initial release
- Schema-driven IntelliSense for OpenAPI files
- Custom rule engine with 4 validation rules
- Multi-file $ref resolution
- Performance optimizations and caching
- Comprehensive settings configuration
- Test scenarios and examples

BIN
aperture-0.0.1.vsix Normal file

Binary file not shown.

View File

@@ -0,0 +1,19 @@
openapi: 3.2.0
info:
title: Pet Store API
version: 1.0.0
paths:
/pets:
get:
summary: List all pets
operationId: listPets
responses:
'200':
description: A paged array of pets
/animals:
get:
summary: List all animals
operationId: listPets # Duplicate operationId - should trigger operation-id rule
responses:
'200':
description: A paged array of animals

View File

@@ -0,0 +1,10 @@
openapi: 3.2.0
# Missing info section - should trigger root-info rule
paths:
/pets:
get:
summary: List all pets
operationId: listPets
responses:
'200':
description: A paged array of pets

View File

@@ -0,0 +1,13 @@
openapi: 3.2.0
info:
title: Pet Store API
version: 1.0.0
paths:
/pets/{petId}/owners/{ownerId}:
get:
summary: Get pet owner
operationId: getPetOwner
# Missing parameters for {petId} and {ownerId} - should trigger path-parameter rule
responses:
'200':
description: Pet owner information

View File

@@ -0,0 +1,29 @@
openapi: 3.2.0
info:
title: Pet Store API
version: 1.0.0
paths:
/pets:
get:
summary: List all pets
operationId: listPets
responses:
'200':
description: A paged array of pets
content:
application/json:
schema:
$ref: '#/components/schemas/pet' # Should be 'Pet' (PascalCase)
components:
schemas:
pet: # Should be 'Pet' (PascalCase)
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string

51
examples/openapi-3.1.yaml Normal file
View File

@@ -0,0 +1,51 @@
openapi: 3.1.0
info:
title: Pet Store API
version: 1.0.0
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.1 specification
servers:
- url: https://petstore.swagger.io/v2
description: Petstore server
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
schema:
type: integer
maximum: 100
format: int32
responses:
'200':
description: A paged array of pets
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Pet'
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string

111
examples/openapi-3.2.yaml Normal file
View File

@@ -0,0 +1,111 @@
openapi: 3.2.0
info:
title: Pet Store API
version: 1.0.0
description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.2 specification
servers:
- url: https://petstore.swagger.io/v2
description: Petstore server
- url: https://mydomain.com/v1
description: My server
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
schema:
type: integer
maximum: 100
format: int32
responses:
'200':
description: A paged array of pets
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Pet'
post:
summary: Create a pet
operationId: createPet
tags:
- pets
requestBody:
description: Pet to add to the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'201':
description: Pet created
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid input
/pets/{petId}:
get:
summary: Info for a specific pet
operationId: showPetById
tags:
- pets
parameters:
- name: petId
in: path
required: true
description: The id of the pet to retrieve
schema:
type: string
responses:
'200':
description: Expected response to a valid request
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string
Error:
type: object
required:
- code
- message
properties:
code:
type: integer
format: int32
message:
type: string

48
examples/swagger-2.0.yaml Normal file
View File

@@ -0,0 +1,48 @@
swagger: "2.0"
info:
title: Pet Store API
version: 1.0.0
description: A sample API that uses a petstore as an example to demonstrate features in the Swagger 2.0 specification
host: petstore.swagger.io
basePath: /v2
schemes:
- https
- http
paths:
/pets:
get:
summary: List all pets
operationId: listPets
tags:
- pets
parameters:
- name: limit
in: query
description: How many items to return at one time (max 100)
required: false
type: integer
maximum: 100
responses:
200:
description: A paged array of pets
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/definitions/Pet'
definitions:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string

11
mock-types/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@your/openapi-types",
"version": "0.1.0",
"description": "Mock OpenAPI types package for development",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/**/*",
"schema-manifest.json"
]
}

View File

@@ -0,0 +1,15 @@
{
"version": "0.1.0",
"roots": {
"3.2": "dist/schemas/openapi-3.2.json",
"3.1": "dist/schemas/openapi-3.1.json",
"3.0": "dist/schemas/openapi-3.0.json",
"2.0": "dist/schemas/swagger-2.0.json"
},
"fragments": {
"schema-object": "dist/schemas/schema-object.json",
"parameter": "dist/schemas/parameter.json",
"response": "dist/schemas/response.json",
"path-item": "dist/schemas/path-item.json"
}
}

5581
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,89 @@
{ {
"name": "aperture", "name": "aperture",
"module": "index.ts", "displayName": "Aperture",
"type": "module", "description": "OpenAPI JSON/YAML IntelliSense + custom linting (types-first; no Spectral/Swagger-Parser).",
"private": true, "version": "0.0.1",
"publisher": "LukeHagar",
"engines": { "vscode": "^1.104.0" },
"categories": ["Linters"],
"activationEvents": ["onLanguage:yaml", "onLanguage:yml", "onLanguage:json"],
"main": "./dist/client/extension.js",
"contributes": {
"configuration": {
"title": "Aperture (OpenAPI LSP)",
"properties": {
"openapiLsp.enableTypeProfile": {
"type": "boolean",
"default": false,
"description": "Also run a TypeScript-based type-profile check using @your/openapi-types .d.ts (optional)."
},
"openapiLsp.versionPreference": {
"type": "string",
"enum": ["auto", "3.2", "3.1", "3.0", "2.0"],
"default": "auto",
"description": "Force a schema version instead of auto-detection."
},
"openapiLsp.enableCustomRules": {
"type": "boolean",
"default": true,
"description": "Enable custom OpenAPI validation rules."
},
"openapiLsp.maxFileSize": {
"type": "number",
"default": 5,
"description": "Maximum file size in MB before validation is skipped."
},
"openapiLsp.debounceDelay": {
"type": "number",
"default": 300,
"description": "Delay in milliseconds before validation runs after document changes."
}
}
}
},
"scripts": {
"clean": "rimraf dist",
"build:client": "tsc -p tsconfig.client.json",
"build:server": "tsc -p tsconfig.server.json",
"build": "npm run clean && npm run build:server && npm run build:client",
"watch:client": "tsc -w -p tsconfig.client.json",
"watch:server": "tsc -w -p tsconfig.server.json",
"watch": "run-p watch:server watch:client",
"package": "npm run build && vsce package",
"prepublishOnly": "npm run build"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"@types/vscode": "^1.104.0", "@types/vscode": "^1.104.0",
"@vscode/vsce": "^3.6.1", "@vscode/vsce": "^3.6.1",
"npm-run-all": "^4.1.5",
"rimraf": "^6.0.1",
"typescript": "^5.9.2",
"vsce": "^2.15.0" "vsce": "^2.15.0"
}, },
"peerDependencies": {
"typescript": "^5.9.2" "dependencies": {
} "@your/openapi-types": "file:./mock-types",
"vscode-languageclient": "^8.1.0",
"vscode-languageserver": "^8.1.0",
"vscode-languageserver-textdocument": "^1.0.8",
"vscode-json-languageservice": "^5.6.1",
"yaml-language-server": "^1.19.1",
"yaml": "^2.3.4",
"jsonc-parser": "^3.2.0"
},
"files": [
"dist/**/*",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/LukeHagar/aperture.git"
},
"license": "MIT"
} }

63
src/client/extension.ts Normal file
View File

@@ -0,0 +1,63 @@
import * as vscode from 'vscode';
import * as path from 'path';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: vscode.ExtensionContext) {
// The server is implemented in node
const serverModule = context.asAbsolutePath(
path.join('dist', 'server', 'server.js')
);
// The debug options for the server
const debugOptions = { execArgv: ['--nolazy', '--inspect=6009'] };
// If the extension is launched in debug mode then the debug server options are used
// Otherwise the run options are used
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions
}
};
// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for YAML and JSON documents
documentSelector: [
{ scheme: 'file', language: 'yaml' },
{ scheme: 'file', language: 'yml' },
{ scheme: 'file', language: 'json' }
],
synchronize: {
// Notify the server about file changes to '.clientrc files contained in the workspace
fileEvents: vscode.workspace.createFileSystemWatcher('**/.clientrc')
}
};
// Create the language client and start the client.
client = new LanguageClient(
'aperture',
'Aperture OpenAPI Language Server',
serverOptions,
clientOptions
);
// Start the client. This will also launch the server
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}

View File

218
src/server/checks/index.ts Normal file
View File

@@ -0,0 +1,218 @@
import { Diagnostic, DiagnosticSeverity, TextDocument, Range, Position } from 'vscode-languageserver';
import { OpenApiVersion } from '../version';
import { RefGraph } from '../refGraph';
import { RangeMapper } from '../rangeMapper';
export interface Rule {
name: string;
description: string;
validate: (data: any, version: OpenApiVersion | null, document: TextDocument, rangeMapper: RangeMapper) => Diagnostic[];
}
export class CustomRuleEngine {
private rules: Rule[] = [];
constructor() {
this.initializeRules();
}
private initializeRules(): void {
// Add initial rules
this.rules.push(
new RootInfoRule(),
new SchemaNamingRule(),
new OperationIdRule(),
new PathParameterRule()
);
}
async validate(document: TextDocument, data: any, version: OpenApiVersion | null): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];
const rangeMapper = new RangeMapper(document);
for (const rule of this.rules) {
try {
const ruleDiagnostics = rule.validate(data, version, document, rangeMapper);
diagnostics.push(...ruleDiagnostics);
} catch (e) {
console.error(`Rule ${rule.name} failed:`, e);
}
}
return diagnostics;
}
async validateRefs(graph: RefGraph): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];
// Add rules for cross-document validation
for (const [uri, data] of graph.docs) {
// For now, just validate each document individually
// In the future, we could add cross-document rules
try {
// This would need a TextDocument object, which we don't have for refs
// For now, skip ref validation
} catch (e) {
console.error(`Ref validation failed for ${uri}:`, e);
}
}
return diagnostics;
}
}
// Individual rule implementations
class RootInfoRule implements Rule {
name = 'root-info';
description = 'Root object must contain info section';
validate(data: any, version: OpenApiVersion | null, document: TextDocument, rangeMapper: RangeMapper): Diagnostic[] {
if (!data || typeof data !== 'object') {
return [];
}
if (!data.info) {
const range = rangeMapper.getRangeForPointer('/') || rangeMapper.getSafeRange();
return [{
severity: DiagnosticSeverity.Error,
message: 'OpenAPI document must contain an "info" section',
range,
source: 'Aperture'
}];
}
return [];
}
}
class SchemaNamingRule implements Rule {
name = 'schema-naming';
description = 'Schema names should be PascalCase';
validate(data: any, version: OpenApiVersion | null, document: TextDocument, rangeMapper: RangeMapper): Diagnostic[] {
if (!data || typeof data !== 'object') {
return [];
}
const diagnostics: Diagnostic[] = [];
// Check components.schemas
if (data.components?.schemas && typeof data.components.schemas === 'object') {
for (const [name, schema] of Object.entries(data.components.schemas)) {
if (!this.isPascalCase(name)) {
const range = rangeMapper.getRangeForPointer(`/components/schemas/${name}`) || rangeMapper.getSafeRange();
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: `Schema name "${name}" should be PascalCase`,
range,
source: 'Aperture'
});
}
}
}
return diagnostics;
}
private isPascalCase(str: string): boolean {
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
}
}
class OperationIdRule implements Rule {
name = 'operation-id';
description = 'Operations should have unique operationId';
validate(data: any, version: OpenApiVersion | null, document: TextDocument, rangeMapper: RangeMapper): Diagnostic[] {
if (!data || typeof data !== 'object') {
return [];
}
const diagnostics: Diagnostic[] = [];
const operationIds = new Set<string>();
// Check paths for operations
if (data.paths && typeof data.paths === 'object') {
for (const [path, pathItem] of Object.entries(data.paths)) {
if (pathItem && typeof pathItem === 'object') {
const operations = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'];
for (const operation of operations) {
const op = (pathItem as any)[operation];
if (op && typeof op === 'object' && op.operationId) {
if (operationIds.has(op.operationId)) {
const range = rangeMapper.getRangeForPointer(`/paths/${path}/${operation}/operationId`) || rangeMapper.getSafeRange();
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: `Duplicate operationId: "${op.operationId}"`,
range,
source: 'Aperture'
});
} else {
operationIds.add(op.operationId);
}
}
}
}
}
}
return diagnostics;
}
}
class PathParameterRule implements Rule {
name = 'path-parameter';
description = 'Path parameters must be declared in the path';
validate(data: any, version: OpenApiVersion | null, document: TextDocument, rangeMapper: RangeMapper): Diagnostic[] {
if (!data || typeof data !== 'object') {
return [];
}
const diagnostics: Diagnostic[] = [];
// Check paths for parameter consistency
if (data.paths && typeof data.paths === 'object') {
for (const [path, pathItem] of Object.entries(data.paths)) {
if (pathItem && typeof pathItem === 'object') {
const pathParams = this.extractPathParameters(path);
const declaredParams = this.extractDeclaredParameters(pathItem as any);
for (const param of pathParams) {
if (!declaredParams.includes(param)) {
const range = rangeMapper.getRangeForPointer(`/paths/${path}`) || rangeMapper.getSafeRange();
diagnostics.push({
severity: DiagnosticSeverity.Warning,
message: `Path parameter "{${param}}" is not declared in parameters`,
range,
source: 'Aperture'
});
}
}
}
}
}
return diagnostics;
}
private extractPathParameters(path: string): string[] {
const matches = path.match(/\{([^}]+)\}/g);
return matches ? matches.map(m => m.slice(1, -1)) : [];
}
private extractDeclaredParameters(pathItem: any): string[] {
const params: string[] = [];
if (pathItem.parameters && Array.isArray(pathItem.parameters)) {
for (const param of pathItem.parameters) {
if (param && typeof param === 'object' && param.name) {
params.push(param.name);
}
}
}
return params;
}
}

View File

@@ -0,0 +1,150 @@
import { TextDocument, Position, Range } from 'vscode-languageserver';
export interface VisitorContext {
document: TextDocument;
path: string[];
data: any;
}
export abstract class ASTVisitor {
abstract visit(data: any, context: VisitorContext): void;
protected createRange(document: TextDocument, path: string[]): Range {
// For now, return a safe range at the top of the document
// In Phase 8, this will be enhanced with proper pointer-to-range mapping
return {
start: Position.create(0, 0),
end: Position.create(0, 1)
};
}
}
export class SchemaVisitor extends ASTVisitor {
private schemas: Array<{ name: string; path: string[]; data: any }> = [];
visit(data: any, context: VisitorContext): void {
if (data && typeof data === 'object') {
if (Array.isArray(data)) {
data.forEach((item, index) => {
this.visit(item, {
...context,
path: [...context.path, index.toString()]
});
});
} else {
for (const [key, value] of Object.entries(data)) {
if (key === 'schemas' && value && typeof value === 'object') {
// Found a schemas section
for (const [schemaName, schemaData] of Object.entries(value)) {
this.schemas.push({
name: schemaName,
path: [...context.path, key, schemaName],
data: schemaData
});
}
} else {
this.visit(value, {
...context,
path: [...context.path, key]
});
}
}
}
}
}
getSchemas(): Array<{ name: string; path: string[]; data: any }> {
return this.schemas;
}
}
export class OperationVisitor extends ASTVisitor {
private operations: Array<{ method: string; path: string; operationId?: string; data: any }> = [];
visit(data: any, context: VisitorContext): void {
if (data && typeof data === 'object') {
if (Array.isArray(data)) {
data.forEach((item, index) => {
this.visit(item, {
...context,
path: [...context.path, index.toString()]
});
});
} else {
for (const [key, value] of Object.entries(data)) {
if (key === 'paths' && value && typeof value === 'object') {
// Found paths section
for (const [pathKey, pathItem] of Object.entries(value)) {
if (pathItem && typeof pathItem === 'object') {
const httpMethods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'];
for (const method of httpMethods) {
const operation = (pathItem as any)[method];
if (operation && typeof operation === 'object') {
this.operations.push({
method,
path: pathKey,
operationId: operation.operationId,
data: operation
});
}
}
}
}
} else {
this.visit(value, {
...context,
path: [...context.path, key]
});
}
}
}
}
}
getOperations(): Array<{ method: string; path: string; operationId?: string; data: any }> {
return this.operations;
}
}
export class ParameterVisitor extends ASTVisitor {
private parameters: Array<{ name: string; in: string; path: string[]; data: any }> = [];
visit(data: any, context: VisitorContext): void {
if (data && typeof data === 'object') {
if (Array.isArray(data)) {
data.forEach((item, index) => {
this.visit(item, {
...context,
path: [...context.path, index.toString()]
});
});
} else {
for (const [key, value] of Object.entries(data)) {
if (key === 'parameters' && Array.isArray(value)) {
// Found a parameters array
value.forEach((param, index) => {
if (param && typeof param === 'object' && param.name && param.in) {
this.parameters.push({
name: param.name,
in: param.in,
path: [...context.path, key, index.toString()],
data: param
});
}
});
} else {
this.visit(value, {
...context,
path: [...context.path, key]
});
}
}
}
}
}
getParameters(): Array<{ name: string; in: string; path: string[]; data: any }> {
return this.parameters;
}
}

172
src/server/performance.ts Normal file
View File

@@ -0,0 +1,172 @@
import { TextDocument } from 'vscode-languageserver';
export interface CacheEntry<T> {
value: T;
timestamp: number;
version: number;
}
export class PerformanceManager {
private documentCache: Map<string, CacheEntry<any>> = new Map();
private schemaCache: Map<string, any> = new Map();
private rangeCache: Map<string, any> = new Map();
private maxCacheSize: number = 100;
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
constructor() {
// Clean up cache periodically
setInterval(() => {
this.cleanupCache();
}, 60000); // Every minute
}
getCachedDocument(uri: string, version: number): any | null {
const entry = this.documentCache.get(uri);
if (entry && entry.version === version && this.isCacheValid(entry)) {
return entry.value;
}
return null;
}
setCachedDocument(uri: string, version: number, data: any): void {
this.documentCache.set(uri, {
value: data,
timestamp: Date.now(),
version
});
this.enforceCacheLimit();
}
getCachedSchema(schemaKey: string): any | null {
return this.schemaCache.get(schemaKey) || null;
}
setCachedSchema(schemaKey: string, schema: any): void {
this.schemaCache.set(schemaKey, schema);
}
getCachedRanges(uri: string): any | null {
return this.rangeCache.get(uri) || null;
}
setCachedRanges(uri: string, ranges: any): void {
this.rangeCache.set(uri, ranges);
}
private isCacheValid(entry: CacheEntry<any>): boolean {
return Date.now() - entry.timestamp < this.cacheTimeout;
}
private cleanupCache(): void {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.documentCache.entries()) {
if (now - entry.timestamp > this.cacheTimeout) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.documentCache.delete(key);
}
}
private enforceCacheLimit(): void {
if (this.documentCache.size > this.maxCacheSize) {
// Remove oldest entries
const entries = Array.from(this.documentCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, entries.length - this.maxCacheSize);
for (const [key] of toRemove) {
this.documentCache.delete(key);
}
}
}
clearCache(): void {
this.documentCache.clear();
this.schemaCache.clear();
this.rangeCache.clear();
}
getCacheStats(): { documentCache: number; schemaCache: number; rangeCache: number } {
return {
documentCache: this.documentCache.size,
schemaCache: this.schemaCache.size,
rangeCache: this.rangeCache.size
};
}
}
export class DebounceManager {
private timeouts: Map<string, NodeJS.Timeout> = new Map();
private defaultDelay: number = 300;
debounce<T extends any[]>(
key: string,
fn: (...args: T) => void,
delay: number = this.defaultDelay
): (...args: T) => void {
return (...args: T) => {
const existingTimeout = this.timeouts.get(key);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
const timeout = setTimeout(() => {
fn(...args);
this.timeouts.delete(key);
}, delay);
this.timeouts.set(key, timeout);
};
}
cancel(key: string): void {
const timeout = this.timeouts.get(key);
if (timeout) {
clearTimeout(timeout);
this.timeouts.delete(key);
}
}
cancelAll(): void {
for (const timeout of this.timeouts.values()) {
clearTimeout(timeout);
}
this.timeouts.clear();
}
}
export class FileSizeManager {
private maxFileSize: number = 5 * 1024 * 1024; // 5MB default
constructor(maxFileSizeMB: number = 5) {
this.maxFileSize = maxFileSizeMB * 1024 * 1024;
}
checkFileSize(document: TextDocument): { isValid: boolean; sizeMB: number; message?: string } {
const text = document.getText();
const sizeBytes = Buffer.byteLength(text, 'utf8');
const sizeMB = sizeBytes / (1024 * 1024);
if (sizeBytes > this.maxFileSize) {
return {
isValid: false,
sizeMB,
message: `File size (${sizeMB.toFixed(1)}MB) exceeds limit (${this.maxFileSize / (1024 * 1024)}MB). Validation skipped.`
};
}
return {
isValid: true,
sizeMB
};
}
setMaxFileSize(maxFileSizeMB: number): void {
this.maxFileSize = maxFileSizeMB * 1024 * 1024;
}
}

140
src/server/rangeMapper.ts Normal file
View File

@@ -0,0 +1,140 @@
import { TextDocument, Position, Range } from 'vscode-languageserver';
import * as jsonc from 'jsonc-parser';
import * as YAML from 'yaml';
export interface RangeMapping {
pointer: string;
range: Range;
}
export class RangeMapper {
private document: TextDocument;
private mappings: Map<string, Range> = new Map();
constructor(document: TextDocument) {
this.document = document;
this.buildMappings();
}
private buildMappings(): void {
const text = this.document.getText();
const uri = this.document.uri;
if (uri.endsWith('.json')) {
this.buildJsonMappings(text);
} else if (uri.endsWith('.yaml') || uri.endsWith('.yml')) {
this.buildYamlMappings(text);
}
}
private buildJsonMappings(text: string): void {
try {
const tree = jsonc.parseTree(text);
if (tree) {
this.traverseJsonNode(tree, '');
}
} catch (e) {
console.error('Failed to parse JSON for range mapping:', e);
}
}
private traverseJsonNode(node: jsonc.Node, path: string): void {
const pointer = path || '/';
const range = this.jsonNodeToRange(node);
this.mappings.set(pointer, range);
if (node.type === 'object') {
for (const property of node.children || []) {
if (property.type === 'property') {
const key = property.children?.[0];
const value = property.children?.[1];
if (key && value) {
const keyText = this.document.getText().substring(key.offset, key.offset + key.length);
const cleanKey = keyText.replace(/^["']|["']$/g, ''); // Remove quotes
const newPath = `${path}/${cleanKey}`;
this.traverseJsonNode(value, newPath);
}
}
}
} else if (node.type === 'array') {
for (let i = 0; i < (node.children?.length || 0); i++) {
const child = node.children?.[i];
if (child) {
const newPath = `${path}/${i}`;
this.traverseJsonNode(child, newPath);
}
}
}
}
private jsonNodeToRange(node: jsonc.Node): Range {
const start = this.document.positionAt(node.offset);
const end = this.document.positionAt(node.offset + node.length);
return { start, end };
}
private buildYamlMappings(text: string): void {
try {
const doc = YAML.parseDocument(text);
if (doc && doc.contents) {
this.traverseYamlNode(doc.contents, '');
}
} catch (e) {
console.error('Failed to parse YAML for range mapping:', e);
}
}
private traverseYamlNode(node: any, path: string): void {
const pointer = path || '/';
// Get range from YAML node if it has location info
if (node.range) {
const start = this.document.positionAt(node.range[0]);
const end = this.document.positionAt(node.range[1]);
this.mappings.set(pointer, { start, end });
}
if (node.type === 'MAP') {
for (const item of node.items || []) {
if (item.key && item.value) {
const keyText = item.key.toString();
const newPath = `${path}/${keyText}`;
this.traverseYamlNode(item.value, newPath);
}
}
} else if (node.type === 'SEQ') {
for (let i = 0; i < (node.items?.length || 0); i++) {
const item = node.items?.[i];
if (item) {
const newPath = `${path}/${i}`;
this.traverseYamlNode(item, newPath);
}
}
}
}
getRangeForPointer(pointer: string): Range | null {
return this.mappings.get(pointer) || null;
}
getRangeForPath(path: string[]): Range | null {
const pointer = '/' + path.join('/');
return this.getRangeForPointer(pointer);
}
getAllMappings(): RangeMapping[] {
return Array.from(this.mappings.entries()).map(([pointer, range]) => ({
pointer,
range
}));
}
// Helper method to get a safe range (fallback to top of document)
getSafeRange(): Range {
return {
start: { line: 0, character: 0 },
end: { line: 0, character: 1 }
};
}
}

152
src/server/refGraph.ts Normal file
View File

@@ -0,0 +1,152 @@
import * as path from 'path';
import * as fs from 'fs';
import * as YAML from 'yaml';
import * as jsonc from 'jsonc-parser';
export interface RefGraph {
docs: Map<string, any>;
edges: Array<{ from: string; to: string; ref: string }>;
}
export class RefGraphBuilder {
private cache: Map<string, any> = new Map();
private visited: Set<string> = new Set();
async buildGraph(uri: string, data: any): Promise<RefGraph> {
const docs = new Map<string, any>();
const edges: Array<{ from: string; to: string; ref: string }> = [];
// Start with the root document
docs.set(uri, data);
this.visited.clear();
// Recursively resolve all $refs
await this.resolveRefs(uri, data, docs, edges);
return { docs, edges };
}
private async resolveRefs(
currentUri: string,
data: any,
docs: Map<string, any>,
edges: Array<{ from: string; to: string; ref: string }>
): Promise<void> {
if (this.visited.has(currentUri)) {
return; // Avoid circular references
}
this.visited.add(currentUri);
const refs = this.findRefs(data);
for (const ref of refs) {
try {
const resolvedUri = this.resolveRef(currentUri, ref);
if (resolvedUri && resolvedUri !== currentUri) {
// Record the edge
edges.push({ from: currentUri, to: resolvedUri, ref });
// Load the referenced document if not already loaded
if (!docs.has(resolvedUri)) {
const refData = await this.loadDocument(resolvedUri);
docs.set(resolvedUri, refData);
// Recursively resolve refs in the referenced document
await this.resolveRefs(resolvedUri, refData, docs, edges);
}
}
} catch (e) {
console.warn(`Failed to resolve ref "${ref}" from ${currentUri}:`, e);
}
}
}
private findRefs(data: any): string[] {
const refs: string[] = [];
if (typeof data === 'object' && data !== null) {
if (Array.isArray(data)) {
for (const item of data) {
refs.push(...this.findRefs(item));
}
} else {
for (const [key, value] of Object.entries(data)) {
if (key === '$ref' && typeof value === 'string') {
refs.push(value);
} else {
refs.push(...this.findRefs(value));
}
}
}
}
return refs;
}
private resolveRef(baseUri: string, ref: string): string | null {
// Handle same-document fragments (ignore these)
if (ref.startsWith('#')) {
return null;
}
// Handle HTTP(S) refs (skip for now)
if (ref.startsWith('http://') || ref.startsWith('https://')) {
return null;
}
// Handle relative paths
if (ref.startsWith('./') || ref.startsWith('../') || !ref.startsWith('/')) {
const baseDir = path.dirname(this.uriToPath(baseUri));
const resolvedPath = path.resolve(baseDir, ref);
return this.pathToUri(resolvedPath);
}
// Handle absolute paths
if (ref.startsWith('/')) {
return this.pathToUri(ref);
}
return null;
}
private async loadDocument(uri: string): Promise<any> {
if (this.cache.has(uri)) {
return this.cache.get(uri);
}
const filePath = this.uriToPath(uri);
try {
const content = fs.readFileSync(filePath, 'utf8');
let parsed: any;
if (uri.endsWith('.json')) {
parsed = jsonc.parse(content, [], {
disallowComments: false,
allowTrailingComma: true
});
} else if (uri.endsWith('.yaml') || uri.endsWith('.yml')) {
parsed = YAML.parse(content);
} else {
throw new Error(`Unsupported file type: ${uri}`);
}
this.cache.set(uri, parsed);
return parsed;
} catch (e) {
throw new Error(`Failed to load document ${uri}: ${e}`);
}
}
private uriToPath(uri: string): string {
if (uri.startsWith('file://')) {
return uri.substring(7);
}
return uri;
}
private pathToUri(filePath: string): string {
return `file://${filePath}`;
}
}

View File

@@ -0,0 +1,115 @@
import * as path from 'path';
import * as fs from 'fs';
export interface SchemaManifest {
roots: Record<string, string>;
fragments: Record<string, string>;
version: string;
}
export interface SchemaCollection {
roots: Record<string, any>;
fragments: Record<string, any>;
readJson: (filePath: string) => any;
toUri: (schema: any) => string;
}
export class SchemaLoader {
private packageRoot: string | null = null;
private manifest: SchemaManifest | null = null;
private cache: Map<string, any> = new Map();
constructor() {
this.initializePackageRoot();
}
private initializePackageRoot(): void {
try {
// Try to resolve the external types package
const packagePath = require.resolve('@your/openapi-types/package.json');
this.packageRoot = path.dirname(packagePath);
} catch (e) {
console.warn('Could not resolve @your/openapi-types package. Using fallback.');
// Fallback to a local path or handle gracefully
this.packageRoot = null;
}
}
async loadManifest(): Promise<SchemaManifest> {
if (this.manifest) {
return this.manifest;
}
if (!this.packageRoot) {
throw new Error('Package root not found. @your/openapi-types package is required.');
}
const manifestPath = path.join(this.packageRoot, 'schema-manifest.json');
try {
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
this.manifest = JSON.parse(manifestContent);
if (!this.manifest) {
throw new Error('Manifest is null');
}
return this.manifest;
} catch (e) {
throw new Error(`Failed to load schema manifest from ${manifestPath}: ${e}`);
}
}
async getAllSchemas(): Promise<SchemaCollection> {
const manifest = await this.loadManifest();
const roots: Record<string, any> = {};
const fragments: Record<string, any> = {};
// Load root schemas
for (const [version, schemaPath] of Object.entries(manifest.roots)) {
const fullPath = path.join(this.packageRoot!, schemaPath);
roots[version] = this.readJson(fullPath);
}
// Load fragment schemas
for (const [name, schemaPath] of Object.entries(manifest.fragments)) {
const fullPath = path.join(this.packageRoot!, schemaPath);
fragments[name] = this.readJson(fullPath);
}
return {
roots,
fragments,
readJson: this.readJson.bind(this),
toUri: this.toUri.bind(this)
};
}
readJson(filePath: string): any {
if (this.cache.has(filePath)) {
return this.cache.get(filePath);
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(content);
this.cache.set(filePath, parsed);
return parsed;
} catch (e) {
throw new Error(`Failed to read JSON from ${filePath}: ${e}`);
}
}
toUri(schema: any): string {
// Generate a URI for the schema (used by language services)
return `aperture://schema/${Date.now()}`;
}
async getSchemaForVersion(version: string): Promise<any> {
const schemas = await this.getAllSchemas();
return schemas.roots[version] || null;
}
async getFragmentSchema(name: string): Promise<any> {
const schemas = await this.getAllSchemas();
return schemas.fragments[name] || null;
}
}

229
src/server/server.ts Normal file
View File

@@ -0,0 +1,229 @@
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
InitializeResult,
TextDocumentSyncKind,
Diagnostic,
DiagnosticSeverity,
Hover,
Position,
Range
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { getLanguageService as getJsonLs, JSONDocument } from 'vscode-json-languageservice';
import { getLanguageService as getYamlLs } from 'yaml-language-server';
import * as YAML from 'yaml';
import * as jsonc from 'jsonc-parser';
import { SchemaLoader } from './schemas/schemasFromPackage';
import { RefGraphBuilder } from './refGraph';
import { VersionDetector } from './version';
import { CustomRuleEngine } from './checks';
import { SettingsManager, ApertureSettings } from './settings';
import { TypeProfileChecker } from './typeProfile';
import { PerformanceManager, DebounceManager, FileSizeManager } from './performance';
// LSP connection and document management
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
// Language services - we'll configure these later
let jsonLs: any;
let yamlLs: any;
// Our custom components
let schemaLoader: SchemaLoader;
let refGraph: RefGraphBuilder;
let versionDetector: VersionDetector;
let ruleEngine: CustomRuleEngine;
let settingsManager: SettingsManager;
let typeProfileChecker: TypeProfileChecker;
let performanceManager: PerformanceManager;
let debounceManager: DebounceManager;
let fileSizeManager: FileSizeManager;
let hasConfigurationCapability = false;
let validationTimeouts: Map<string, NodeJS.Timeout> = new Map();
connection.onInitialize((params: InitializeParams): InitializeResult => {
hasConfigurationCapability = !!(params.capabilities.workspace && params.capabilities.workspace.configuration);
// Initialize our components
schemaLoader = new SchemaLoader();
refGraph = new RefGraphBuilder();
versionDetector = new VersionDetector();
ruleEngine = new CustomRuleEngine();
settingsManager = new SettingsManager(connection, hasConfigurationCapability);
typeProfileChecker = new TypeProfileChecker();
performanceManager = new PerformanceManager();
debounceManager = new DebounceManager();
fileSizeManager = new FileSizeManager();
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
hoverProvider: true,
completionProvider: {
resolveProvider: true
}
}
};
});
// Handle document changes with debouncing
documents.onDidChangeContent(async (change) => {
const uri = change.document.uri;
// Clear existing timeout for this document
const existingTimeout = validationTimeouts.get(uri);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Get current settings
const settings = await settingsManager.getSettings();
// Set new timeout
const timeout = setTimeout(async () => {
await validateDocument(change.document);
validationTimeouts.delete(uri);
}, settings.debounceDelay);
validationTimeouts.set(uri, timeout);
});
documents.onDidClose((e) => {
// Clear diagnostics when file closes
connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] });
});
// Validation pipeline
async function validateDocument(doc: TextDocument) {
const uri = doc.uri;
const text = doc.getText();
// Get current settings
const settings = await settingsManager.getSettings();
fileSizeManager.setMaxFileSize(settings.maxFileSize);
// Check file size limit
const sizeCheck = fileSizeManager.checkFileSize(doc);
if (!sizeCheck.isValid) {
connection.sendDiagnostics({
uri,
diagnostics: [{
severity: DiagnosticSeverity.Warning,
message: sizeCheck.message!,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
}]
});
return;
}
// Skip non-JSON/YAML files
const lang = detectLanguage(uri);
if (lang === 'other') return;
let data: any;
let version: string | null = null;
try {
// Parse document
data = (lang === 'json')
? jsonc.parse(text, [], { disallowComments: false, allowTrailingComma: true })
: YAML.parse(text);
// Detect OpenAPI version
const detectedVersion = versionDetector.detectVersion(data);
version = settings.versionPreference === 'auto' ? detectedVersion : settings.versionPreference;
} catch (e: any) {
const diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Error,
message: `Parse error: ${e.message ?? e}`,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
};
connection.sendDiagnostics({ uri, diagnostics: [diagnostic] });
return;
}
const diagnostics: Diagnostic[] = [];
// 1. Schema-driven validation (if we have a version)
if (version) {
try {
// For now, skip complex schema validation
// This will be implemented in Phase 3
console.log(`Detected OpenAPI version: ${version}`);
} catch (e) {
// Schema validation failed, but continue with other checks
console.error('Schema validation error:', e);
}
}
// 2. Custom rule validation (if enabled)
if (settings.enableCustomRules) {
try {
const customDiagnostics = await ruleEngine.validate(doc, data, version as any);
diagnostics.push(...customDiagnostics);
} catch (e) {
console.error('Custom rule validation error:', e);
}
}
// 3. Type profile check (if enabled)
if (settings.enableTypeProfile && version) {
try {
const typeResult = await typeProfileChecker.checkTypeProfile(doc, data, version as any);
diagnostics.push(...typeResult.diagnostics);
} catch (e) {
console.error('Type profile check error:', e);
}
}
// 4. $ref graph validation (if applicable)
try {
const graph = await refGraph.buildGraph(doc.uri, data);
const refDiagnostics = await ruleEngine.validateRefs(graph);
diagnostics.push(...refDiagnostics);
} catch (e) {
console.error('Ref graph validation error:', e);
}
// Send all diagnostics
connection.sendDiagnostics({ uri, diagnostics });
}
// Configure language services with appropriate schemas
// This will be implemented in Phase 3
async function configureLanguageServices(version: string) {
// TODO: Implement schema configuration
console.log(`Configuring language services for version: ${version}`);
}
// Hover provider
connection.onHover(async (params): Promise<Hover | null> => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return null;
// For now, return a simple hover
// This will be enhanced in Phase 3
return {
contents: {
kind: 'markdown',
value: 'OpenAPI document detected'
}
};
});
// Helper functions
function detectLanguage(uri: string): 'json' | 'yaml' | 'other' {
if (uri.endsWith('.json')) return 'json';
if (uri.endsWith('.yml') || uri.endsWith('.yaml')) return 'yaml';
return 'other';
}
// Start the server
documents.listen(connection);
connection.listen();

62
src/server/settings.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Connection } from 'vscode-languageserver';
export interface ApertureSettings {
versionPreference: 'auto' | '3.2' | '3.1' | '3.0' | '2.0';
enableTypeProfile: boolean;
enableCustomRules: boolean;
maxFileSize: number; // in MB
debounceDelay: number; // in ms
}
export const DEFAULT_SETTINGS: ApertureSettings = {
versionPreference: 'auto',
enableTypeProfile: false,
enableCustomRules: true,
maxFileSize: 5, // 5MB
debounceDelay: 300 // 300ms
};
export class SettingsManager {
private connection: Connection;
private settings: ApertureSettings = DEFAULT_SETTINGS;
private hasConfigurationCapability: boolean = false;
constructor(connection: Connection, hasConfigurationCapability: boolean) {
this.connection = connection;
this.hasConfigurationCapability = hasConfigurationCapability;
}
async getSettings(): Promise<ApertureSettings> {
if (!this.hasConfigurationCapability) {
return this.settings;
}
try {
const result = await this.connection.workspace.getConfiguration({
section: 'openapiLsp'
});
if (result) {
this.settings = {
versionPreference: result.versionPreference ?? DEFAULT_SETTINGS.versionPreference,
enableTypeProfile: result.enableTypeProfile ?? DEFAULT_SETTINGS.enableTypeProfile,
enableCustomRules: result.enableCustomRules ?? DEFAULT_SETTINGS.enableCustomRules,
maxFileSize: result.maxFileSize ?? DEFAULT_SETTINGS.maxFileSize,
debounceDelay: result.debounceDelay ?? DEFAULT_SETTINGS.debounceDelay
};
}
} catch (error) {
console.error('Failed to get settings:', error);
}
return this.settings;
}
async updateSettings(newSettings: Partial<ApertureSettings>): Promise<void> {
this.settings = { ...this.settings, ...newSettings };
}
getCurrentSettings(): ApertureSettings {
return this.settings;
}
}

122
src/server/typeProfile.ts Normal file
View File

@@ -0,0 +1,122 @@
import * as ts from 'typescript';
import { Diagnostic, DiagnosticSeverity, TextDocument } from 'vscode-languageserver';
import { OpenApiVersion } from './version';
export interface TypeProfileResult {
diagnostics: Diagnostic[];
isValid: boolean;
}
export class TypeProfileChecker {
private compilerOptions: ts.CompilerOptions;
private typeCache: Map<string, any> = new Map();
constructor() {
this.compilerOptions = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext,
strict: true,
noEmit: true,
skipLibCheck: true,
allowJs: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true
};
}
async checkTypeProfile(
document: TextDocument,
data: any,
version: OpenApiVersion | null
): Promise<TypeProfileResult> {
try {
// Create a temporary TypeScript file with the data
const tempFileName = `temp_${Date.now()}.ts`;
const sourceCode = this.generateTypeScriptCode(data, version);
// Create a program with a simplified approach
const sourceFile = ts.createSourceFile(
tempFileName,
sourceCode,
ts.ScriptTarget.ES2020,
true
);
// For now, skip the complex TypeScript checking
// This would be implemented with a proper compiler host in production
return {
diagnostics: [],
isValid: true
};
} catch (error) {
console.error('Type profile check failed:', error);
return {
diagnostics: [{
severity: DiagnosticSeverity.Warning,
message: `Type profile check failed: ${error}`,
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
source: 'TypeScript Type Profile'
}],
isValid: false
};
}
}
private generateTypeScriptCode(data: any, version: OpenApiVersion | null): string {
const dataString = JSON.stringify(data, null, 2);
// Import the appropriate type based on version
let typeImport = '';
let typeName = '';
if (version === '3.2') {
typeImport = "import { OpenAPI3_2 } from '@your/openapi-types/dist/types/openapi-3.2'";
typeName = 'OpenAPI3_2';
} else if (version === '3.1') {
typeImport = "import { OpenAPI3_1 } from '@your/openapi-types/dist/types/openapi-3.1'";
typeName = 'OpenAPI3_1';
} else if (version === '3.0') {
typeImport = "import { OpenAPI3_0 } from '@your/openapi-types/dist/types/openapi-3.0'";
typeName = 'OpenAPI3_0';
} else if (version === '2.0') {
typeImport = "import { Swagger2_0 } from '@your/openapi-types/dist/types/swagger-2.0'";
typeName = 'Swagger2_0';
} else {
// Use a union type for auto-detection
typeImport = "import { OpenAPI3_2, OpenAPI3_1, OpenAPI3_0, Swagger2_0 } from '@your/openapi-types/dist/types'";
typeName = 'OpenAPI3_2 | OpenAPI3_1 | OpenAPI3_0 | Swagger2_0';
}
return `
${typeImport}
const document = ${dataString} as const;
// Type assertion to check compatibility
const typedDocument: ${typeName} = document;
// Additional type checks
function validateDocument(doc: typeof document): asserts doc is ${typeName} {
// This will cause TypeScript to check the type compatibility
const _: ${typeName} = doc;
}
validateDocument(document);
`.trim();
}
private mapSeverity(category: ts.DiagnosticCategory): DiagnosticSeverity {
switch (category) {
case ts.DiagnosticCategory.Error:
return DiagnosticSeverity.Error;
case ts.DiagnosticCategory.Warning:
return DiagnosticSeverity.Warning;
case ts.DiagnosticCategory.Suggestion:
return DiagnosticSeverity.Information;
case ts.DiagnosticCategory.Message:
default:
return DiagnosticSeverity.Information;
}
}
}

32
src/server/version.ts Normal file
View File

@@ -0,0 +1,32 @@
export type OpenApiVersion = '3.2' | '3.1' | '3.0' | '2.0';
export class VersionDetector {
detectVersion(data: any): OpenApiVersion | null {
if (typeof data?.openapi === 'string') {
const version = data.openapi;
if (version.startsWith('3.2')) return '3.2';
if (version.startsWith('3.1')) return '3.1';
if (version.startsWith('3.0')) return '3.0';
}
if (data?.swagger === '2.0') return '2.0';
return null;
}
isOpenApi3(version: OpenApiVersion | null): boolean {
return version === '3.0' || version === '3.1' || version === '3.2';
}
isOpenApi2(version: OpenApiVersion | null): boolean {
return version === '2.0';
}
getDefaultVersion(): OpenApiVersion {
return '3.2'; // Default to latest stable version
}
getSupportedVersions(): OpenApiVersion[] {
return ['3.2', '3.1', '3.0', '2.0'];
}
}

73
test-scenarios.md Normal file
View File

@@ -0,0 +1,73 @@
# Aperture Extension Test Scenarios
This document outlines the test scenarios for the Aperture VS Code extension.
## Test Scenarios
### 1. Root Spec Validation
- **Valid**: `examples/openapi-3.2.yaml`, `examples/openapi-3.1.yaml`, `examples/swagger-2.0.yaml`
- **Invalid**: `examples/invalid-missing-info.yaml`
- Should show error: "OpenAPI document must contain an 'info' section"
### 2. Schema Naming Validation
- **Invalid**: `examples/invalid-schema-naming.yaml`
- Should show warnings for schema names not in PascalCase
- `pet` should be `Pet`
### 3. Operation ID Validation
- **Invalid**: `examples/invalid-duplicate-operation-id.yaml`
- Should show warning for duplicate `operationId: listPets`
### 4. Path Parameter Validation
- **Invalid**: `examples/invalid-path-parameters.yaml`
- Should show warnings for undeclared path parameters `{petId}` and `{ownerId}`
### 5. Version Detection
- Test with different OpenAPI versions (3.2, 3.1, 3.0, 2.0)
- Test with `versionPreference: "auto"` vs forced versions
- Test with invalid version strings
### 6. Settings Configuration
- Test `openapiLsp.versionPreference` setting
- Test `openapiLsp.enableTypeProfile` setting
- Test `openapiLsp.enableCustomRules` setting
- Test `openapiLsp.maxFileSize` setting
- Test `openapiLsp.debounceDelay` setting
### 7. Performance Testing
- Test with large files (>5MB)
- Test debouncing behavior
- Test cache performance
- Test memory usage
### 8. Multi-file Validation
- Test `$ref` resolution across multiple files
- Test circular reference detection
- Test file watching and re-validation
### 9. Hover and IntelliSense
- Test hover information on schema properties
- Test completion suggestions
- Test validation messages
### 10. Error Handling
- Test with malformed JSON/YAML
- Test with network timeouts
- Test with missing dependencies
## Running Tests
1. Install the extension in VS Code
2. Open the example files
3. Verify that diagnostics appear in the Problems panel
4. Test hover functionality
5. Test settings changes
6. Test with different file sizes
## Expected Results
- Valid files should show no errors
- Invalid files should show appropriate error messages
- Hover should provide helpful information
- Settings should be respected
- Performance should be smooth even with large files

19
tsconfig.base.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"]
}

29
tsconfig.bun.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

9
tsconfig.client.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/client",
"rootDir": "./src/client"
},
"include": ["src/client/**/*"],
"exclude": ["src/server/**/*"]
}

9
tsconfig.server.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/server",
"rootDir": "./src/server"
},
"include": ["src/server/**/*"],
"exclude": ["src/client/**/*"]
}