Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,87 @@ task prepare:vscode
3. Next, open a VSCode instance at the root of the project, open the
`Run and Debug` tab and run it via the `Run Extension` on the dropdown menu
at the top of the tab.

## Understanding the Schema Files

The CircleCI YAML Language Server uses **two different schema files** for different purposes:

| File | Purpose | Used By |
| ------------------- | -------------------------------------------- | ------------------------------------------- |
| `schema.json` | Core validation and language server features | Go language server binary, external tools |
| `publicschema.json` | Rich hover documentation | VSCode extension TypeScript hover providers |

### Architecture

This is a **two-tier schema system**:

### `schema.json`

**Primary Purpose**: Validates the YAML is valid according to our CircleCI rules

**Used By**:

- **Go Language Server Binary**: The main language server reads this schema via the `SCHEMA_LOCATION` environment variable
- Location: `pkg/services/diagnostics.go`, `pkg/services/validate.go`, etc.

- **External Tools**: Used by the Red Hat YAML extension. This extension looks at [schemastore.org](https://www.schemastore.org/api/json/catalog.json), which reads the latest schema.json from this repo.
- URL: `https://raw.githubusercontent.com/CircleCI-Public/circleci-yaml-language-server/refs/heads/main/schema.json`

- **VSCode Extension**: Downloaded from GitHub releases page and bundled with the extension
- Location in our private VSCode extension

- **Go Tests**: Used for validation testing
- Location: `pkg/services/diagnostics_test.go`

**Characteristics**:

- JSON Schema draft-07

### `publicschema.json`

**Primary Purpose**: Documentation for IDE hover features

**Used By**:

- **VSCode Extension Hover Provider**
- Location: `circleci-vscode-extension/packages/vscode-extension/src/lsp/hover.ts:62-67`

**Characteristics**:

- JSON Schema draft-04
- Includes inline CircleCI documentation URLs (e.g., `https://circleci.com/docs/configuration-reference#...`)
- **Never used by the Go language server**

### Why Two Schemas?

The separation exists because:

- The Go language server needs a comprehensive schema for validation that handles all edge cases
- The hover provider needs clean documentation with links to CircleCI docs

### Development Guidelines

#### When to Update `schema.json`

Update this schema when:

- Adding or modifying CircleCI config validation rules
- Changing supported configuration keys or values
- Adding new CircleCI features that affect config structure
- Fixing validation bugs

#### When to Update `publicschema.json`

Update this schema when:

- Improving hover documentation text
- Adding or updating links to CircleCI documentation
- Changing the structure of hover hints
- Making documentation more user-friendly

#### Keeping Schemas in Sync

> ⚠️ [!IMPORTANT]
> Both schemas should represent the same CircleCI configuration format. When you update one schema's structure, you likely need to update the other.

**Best Practice**: Make structural changes to both schemas in the same PR to prevent drift.
6 changes: 6 additions & 0 deletions pkg/ast/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ type Job struct {

Type string
TypeRange protocol.Range

PlanName string
PlanNameRange protocol.Range

Key string
KeyRange protocol.Range
}

func (job *Job) AddCompletionItem(label string, commitCharacters []string) {
Expand Down
15 changes: 15 additions & 0 deletions pkg/parser/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ func (doc *YamlDocument) parseSingleJob(jobNode *sitter.Node) ast.Job {
case "type":
res.Type = doc.GetNodeText(valueNode)
res.TypeRange = doc.NodeToRange(child)

case "plan_name":
res.PlanName = doc.GetNodeText(valueNode)
res.PlanNameRange = doc.NodeToRange(child)

case "key":
res.Key = doc.GetNodeText(valueNode)
res.KeyRange = doc.NodeToRange(child)

}
}
})
Expand Down Expand Up @@ -165,4 +174,10 @@ func (doc *YamlDocument) jobCompletionItem(job ast.Job) {
if job.Type == "" {
job.AddCompletionItem("type", []string{":", " "})
}
if job.PlanName == "" {
job.AddCompletionItem("plan_name", []string{":", " "})
}
if job.Key == "" {
job.AddCompletionItem("key", []string{":", " "})
}
}
259 changes: 259 additions & 0 deletions pkg/parser/jsonschema_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package parser

import (
"strings"
"testing"

"github.com/CircleCI-Public/circleci-yaml-language-server/pkg/expect"
Expand Down Expand Up @@ -72,3 +73,261 @@ test:

expect.DiagnosticList(t, diagnostics).To.Include(expected)
}

func Test_JobDefinitionTypes(t *testing.T) {
testCases := []struct {
name string
yaml string
expectError bool
expectErrorContains string
}{
// Build type tests
{
name: "build type - valid with docker and steps",
yaml: `
version: 2.1
jobs:
my-build-job:
type: build
docker:
- image: cimg/base:2023.01
steps:
- checkout
`,
expectError: false,
},
{
name: "build type - valid with explicit type",
yaml: `
version: 2.1
jobs:
my-build-job:
type: build
docker:
- image: cimg/base:2023.01
steps:
- checkout
- run: echo "build job with explicit type"
`,
expectError: false,
},
{
name: "build type - missing steps (should error)",
yaml: `
version: 2.1
jobs:
my-build-job:
type: build
docker:
- image: cimg/base:2023.01
`,
expectError: true,
expectErrorContains: "steps",
},

// Release type tests
{
name: "release type - valid with plan_name",
yaml: `
version: 2.1
jobs:
my-release-job:
type: release
plan_name: my-release-plan
`,
expectError: false,
},
{
name: "release type - valid with additional properties",
yaml: `
version: 2.1
jobs:
my-release-job:
type: release
plan_name: my-plan
some_other_property: allowed
`,
expectError: false,
},
{
name: "release type - missing plan_name (should error)",
yaml: `
version: 2.1
jobs:
my-release-job:
type: release
`,
expectError: true,
expectErrorContains: "plan_name",
},

// Lock type tests
{
name: "lock type - valid with key",
yaml: `
version: 2.1
jobs:
my-lock-job:
type: lock
key: my-lock-key
`,
expectError: false,
},
{
name: "lock type - valid with additional properties",
yaml: `
version: 2.1
jobs:
my-lock-job:
type: lock
key: my-key
some_other_property: allowed
`,
expectError: false,
},
{
name: "lock type - missing key (should error)",
yaml: `
version: 2.1
jobs:
my-lock-job:
type: lock
`,
expectError: true,
expectErrorContains: "key",
},

// Unlock type tests
{
name: "unlock type - valid with key",
yaml: `
version: 2.1
jobs:
my-unlock-job:
type: unlock
key: my-lock-key
`,
expectError: false,
},
{
name: "unlock type - valid with additional properties",
yaml: `
version: 2.1
jobs:
my-unlock-job:
type: unlock
key: my-key
some_other_property: allowed
`,
expectError: false,
},
{
name: "unlock type - missing key (should error)",
yaml: `
version: 2.1
jobs:
my-unlock-job:
type: unlock
`,
expectError: true,
expectErrorContains: "key",
},

// Approval type tests
{
name: "approval type - valid minimal",
yaml: `
version: 2.1
jobs:
my-approval-job:
type: approval
`,
expectError: false,
},
{
name: "approval type - valid with steps (ignored)",
yaml: `
version: 2.1
jobs:
my-approval-job:
type: approval
steps:
- run: echo "This will be ignored"
`,
expectError: false,
},

// No-op type tests
{
name: "no-op type - valid minimal",
yaml: `
version: 2.1
jobs:
my-noop-job:
type: no-op
`,
expectError: false,
},
{
name: "no-op type - valid with steps (ignored)",
yaml: `
version: 2.1
jobs:
my-noop-job:
type: no-op
steps:
- run: echo "This will be ignored"
`,
expectError: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
context := testHelpers.GetDefaultLsContext()
yamlDocument, _ := ParseFromContent([]byte(tc.yaml), context, uri.File(""), protocol.Position{})

if tc.expectError {
// For error cases, also run JSON schema validation
validator := JSONSchemaValidator{
Doc: yamlDocument,
}

schemaPath := "../../schema.json"
err := validator.LoadJsonSchema(schemaPath)
if err != nil {
t.Logf("Warning: Could not load schema: %v", err)
t.SkipNow()
}

diagnostics := validator.ValidateWithJSONSchema(yamlDocument.RootNode, yamlDocument.Content)

// Log all diagnostics for debugging
if len(diagnostics) > 0 {
t.Logf("Found %d diagnostic(s):", len(diagnostics))
for _, d := range diagnostics {
t.Logf(" - %s", d.Message)
}
} else {
t.Logf("No diagnostics found")
}

assert.NotEmpty(t, diagnostics, "Expected validation errors but got none")
if tc.expectErrorContains != "" {
found := false
for _, d := range diagnostics {
if strings.Contains(strings.ToLower(d.Message), strings.ToLower(tc.expectErrorContains)) {
found = true
break
}
}
assert.True(t, found, "Expected error message to contain '%s'", tc.expectErrorContains)
}
} else {
// For non-error cases, just check that parsing succeeded
diagnostics := yamlDocument.Diagnostics
assert.Empty(t, diagnostics, "Expected no errors but got: %v", diagnostics)
}
})
}
}
Loading