mirror of
https://github.com/LukeHagar/aperture.git
synced 2025-12-06 04:19:09 +00:00
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:
30
.vscode/launch.json
vendored
Normal file
30
.vscode/launch.json
vendored
Normal 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
185
README.md
@@ -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
BIN
aperture-0.0.1.vsix
Normal file
Binary file not shown.
19
examples/invalid-duplicate-operation-id.yaml
Normal file
19
examples/invalid-duplicate-operation-id.yaml
Normal 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
|
||||||
10
examples/invalid-missing-info.yaml
Normal file
10
examples/invalid-missing-info.yaml
Normal 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
|
||||||
13
examples/invalid-path-parameters.yaml
Normal file
13
examples/invalid-path-parameters.yaml
Normal 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
|
||||||
29
examples/invalid-schema-naming.yaml
Normal file
29
examples/invalid-schema-naming.yaml
Normal 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
51
examples/openapi-3.1.yaml
Normal 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
111
examples/openapi-3.2.yaml
Normal 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
48
examples/swagger-2.0.yaml
Normal 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
11
mock-types/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
15
mock-types/schema-manifest.json
Normal file
15
mock-types/schema-manifest.json
Normal 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
5581
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
87
package.json
@@ -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
63
src/client/extension.ts
Normal 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();
|
||||||
|
}
|
||||||
218
src/server/checks/index.ts
Normal file
218
src/server/checks/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/server/checks/visitors.ts
Normal file
150
src/server/checks/visitors.ts
Normal 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
172
src/server/performance.ts
Normal 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
140
src/server/rangeMapper.ts
Normal 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
152
src/server/refGraph.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/server/schemas/schemasFromPackage.ts
Normal file
115
src/server/schemas/schemasFromPackage.ts
Normal 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
229
src/server/server.ts
Normal 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
62
src/server/settings.ts
Normal 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
122
src/server/typeProfile.ts
Normal 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
32
src/server/version.ts
Normal 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
73
test-scenarios.md
Normal 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
19
tsconfig.base.json
Normal 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
29
tsconfig.bun.json
Normal 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
9
tsconfig.client.json
Normal 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
9
tsconfig.server.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/server",
|
||||||
|
"rootDir": "./src/server"
|
||||||
|
},
|
||||||
|
"include": ["src/server/**/*"],
|
||||||
|
"exclude": ["src/client/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user