Skip to content

Commit 9c4a5c7

Browse files
authored
feat(YAML): support untitled cloudformation/sam yaml #2893
Problem: Untitled (unsaved) yaml files for cloudformation/sam do not get a schema associated until the file is saved, so CFN/SAM language features are not enabled. Solution: - Modify the file watcher to support "untitled:" URIs. - Watch "untitled:" yaml files for cloudformation/sam contents and apply the schema accordingly. Test case: 1. create a new untitled file and make sure the language is yaml. 2. add `AWSTemplateFormatVersion` and it should automatically start detecting the schema
1 parent 78c9d05 commit 9c4a5c7

File tree

11 files changed

+118
-56
lines changed

11 files changed

+118
-56
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Feature",
3+
"description": "Enable syntax features for untitled (unsaved) cloudformation/sam yaml files"
4+
}

src/dynamicResources/awsResourceManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export class AwsResourceManager {
100100
remove(uri.fsPath)
101101

102102
globals.schemaService.registerMapping({
103-
path: uri.fsPath,
103+
uri,
104104
type: 'json',
105105
schema: undefined,
106106
})
@@ -170,7 +170,7 @@ export class AwsResourceManager {
170170

171171
private async registerSchema(
172172
typeName: string,
173-
file: vscode.Uri,
173+
uri: vscode.Uri,
174174
cloudFormation: CloudFormationClient
175175
): Promise<void> {
176176
await this.initialize()
@@ -199,7 +199,7 @@ export class AwsResourceManager {
199199

200200
globals.schemaService.registerMapping(
201201
{
202-
path: file.fsPath,
202+
uri,
203203
type: 'json',
204204
schema: location,
205205
},

src/shared/cloudformation/activation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi
3636
globals.templateRegistry = registry
3737
await registry.addExcludedPattern(TEMPLATE_FILE_EXCLUDE_PATTERN)
3838
await registry.addWatchPattern(TEMPLATE_FILE_GLOB_PATTERN)
39+
await registry.watchUntitledFiles()
3940
} catch (e) {
4041
vscode.window.showErrorMessage(
4142
localize(

src/shared/cloudformation/cloudformation.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,14 +371,17 @@ export namespace CloudFormation {
371371
}
372372

373373
const templateAsYaml: string = await filesystemUtilities.readFileAsString(filename)
374-
const template = yaml.load(templateAsYaml, {
374+
return loadByContents(templateAsYaml, validate)
375+
}
376+
377+
export async function loadByContents(contents: string, validate: boolean = true): Promise<Template> {
378+
const template = yaml.load(contents, {
375379
schema: schema as any,
376380
}) as Template
377381

378382
if (validate) {
379383
validateTemplate(template)
380384
}
381-
382385
return template
383386
}
384387

src/shared/cloudformation/templateRegistry.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,28 @@ import { getLambdaDetails } from '../../lambda/utils'
1414
import { WatchedFiles, WatchedItem } from '../watchedFiles'
1515
import { getLogger } from '../logger'
1616
import globals from '../extensionGlobals'
17-
18-
export interface TemplateDatum {
19-
path: string
20-
template: CloudFormation.Template
21-
}
17+
import { isUntitledScheme, normalizeVSCodeUri } from '../utilities/vsCodeUtils'
2218

2319
export class CloudFormationTemplateRegistry extends WatchedFiles<CloudFormation.Template> {
2420
protected name: string = 'CloudFormationTemplateRegistry'
25-
protected async load(path: string): Promise<CloudFormation.Template | undefined> {
21+
protected async process(uri: vscode.Uri, contents?: string): Promise<CloudFormation.Template | undefined> {
2622
// P0: Assume all template.yaml/yml files are CFN templates and assign correct JSON schema.
2723
// P1: Alter registry functionality to search ALL YAML files and apply JSON schemas + add to registry based on validity
2824

2925
let template: CloudFormation.Template | undefined
26+
const path = normalizeVSCodeUri(uri)
3027
try {
31-
template = await CloudFormation.load(path, false)
28+
if (isUntitledScheme(uri)) {
29+
if (!contents) {
30+
// this error technically just throw us into the catch so the error message isn't used
31+
throw new Error('Contents must be defined for untitled uris')
32+
}
33+
template = await CloudFormation.loadByContents(contents, false)
34+
} else {
35+
template = await CloudFormation.load(path, false)
36+
}
3237
} catch (e) {
33-
globals.schemaService.registerMapping({ path, type: 'yaml', schema: undefined })
38+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: undefined })
3439
return undefined
3540
}
3641

@@ -39,26 +44,27 @@ export class CloudFormationTemplateRegistry extends WatchedFiles<CloudFormation.
3944
if (template.AWSTemplateFormatVersion || template.Resources) {
4045
if (template.Transform && template.Transform.toString().startsWith('AWS::Serverless')) {
4146
// apply serverless schema
42-
globals.schemaService.registerMapping({ path, type: 'yaml', schema: 'sam' })
47+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'sam' })
4348
} else {
4449
// apply cfn schema
45-
globals.schemaService.registerMapping({ path, type: 'yaml', schema: 'cfn' })
50+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'cfn' })
4651
}
4752

4853
return template
4954
}
5055

51-
globals.schemaService.registerMapping({ path, type: 'yaml', schema: undefined })
56+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: undefined })
5257
return undefined
5358
}
5459

5560
// handles delete case
56-
public async remove(path: string | vscode.Uri): Promise<void> {
61+
public async remove(uri: vscode.Uri): Promise<void> {
5762
globals.schemaService.registerMapping({
58-
path: typeof path === 'string' ? path : pathutils.normalize(path.fsPath),
63+
uri,
5964
type: 'yaml',
6065
schema: undefined,
6166
})
67+
const path = normalizeVSCodeUri(uri)
6268
await super.remove(path)
6369
}
6470
}

src/shared/sam/codelensRootRegistry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55

66
import * as path from 'path'
7+
import * as pathutils from '../utilities/pathUtils'
8+
import * as vscode from 'vscode'
79
import { WatchedFiles } from '../watchedFiles'
810

911
/**
@@ -17,7 +19,8 @@ import { WatchedFiles } from '../watchedFiles'
1719
*/
1820
export class CodelensRootRegistry extends WatchedFiles<string> {
1921
protected name: string = 'CodelensRootRegistry'
20-
protected async load(p: string): Promise<string> {
21-
return path.basename(p)
22+
protected async process(p: vscode.Uri): Promise<string> {
23+
const normalizedPath = pathutils.normalize(p.fsPath)
24+
return path.basename(normalizedPath)
2225
}
2326
}

src/shared/schemas.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { FileResourceFetcher } from './resourcefetcher/fileResourceFetcher'
1212
import { getPropertyFromJsonUrl, HttpResourceFetcher } from './resourcefetcher/httpResourceFetcher'
1313
import { Settings } from './settings'
1414
import { once } from './utilities/functionUtils'
15-
import { normalizeSeparator } from './utilities/pathUtils'
1615
import { Any, ArrayConstructor } from './utilities/typeConstructors'
1716
import { AWS_SCHEME } from './constants'
1817
import { writeFile } from 'fs-extra'
1918
import { SystemUtilities } from './systemUtilities'
19+
import { normalizeVSCodeUri } from './utilities/vsCodeUtils'
2020

2121
const GOFORMATION_MANIFEST_URL = 'https://api.github.com/repos/awslabs/goformation/releases/latest'
2222
const SCHEMA_PREFIX = `${AWS_SCHEME}://`
@@ -25,7 +25,7 @@ export type Schemas = { [key: string]: vscode.Uri }
2525
export type SchemaType = 'yaml' | 'json'
2626

2727
export interface SchemaMapping {
28-
path: string
28+
uri: vscode.Uri
2929
type: SchemaType
3030
schema?: string | vscode.Uri
3131
}
@@ -103,7 +103,7 @@ export class SchemaService {
103103

104104
const batch = this.updateQueue.splice(0, this.updateQueue.length)
105105
for (const mapping of batch) {
106-
const { type, schema, path } = mapping
106+
const { type, schema, uri } = mapping
107107
const handler = this.handlers.get(type)
108108
if (!handler) {
109109
throw new Error(`no registered handler for type ${type}`)
@@ -112,7 +112,7 @@ export class SchemaService {
112112
'schema service: handle %s mapping: %s -> %s',
113113
type,
114114
schema?.toString() ?? '[removed]',
115-
path
115+
uri
116116
)
117117
await handler.handleUpdate(mapping, this.schemas)
118118
}
@@ -309,11 +309,10 @@ export class YamlSchemaHandler implements SchemaHandler {
309309
this.yamlExtension = ext
310310
}
311311

312-
const path = vscode.Uri.file(normalizeSeparator(mapping.path))
313312
if (mapping.schema) {
314-
this.yamlExtension.assignSchema(path, resolveSchema(mapping.schema, schemas))
313+
this.yamlExtension.assignSchema(mapping.uri, resolveSchema(mapping.schema, schemas))
315314
} else {
316-
this.yamlExtension.removeSchema(path)
315+
this.yamlExtension.removeSchema(mapping.uri)
317316
}
318317
}
319318
}
@@ -356,6 +355,7 @@ export class JsonSchemaHandler implements SchemaHandler {
356355

357356
let settings = this.getJsonSettings()
358357

358+
const path = normalizeVSCodeUri(mapping.uri)
359359
if (mapping.schema) {
360360
const uri = resolveSchema(mapping.schema, schemas).toString()
361361
const existing = this.getSettingBy({ schemaPath: uri })
@@ -364,16 +364,16 @@ export class JsonSchemaHandler implements SchemaHandler {
364364
if (!existing.fileMatch) {
365365
getLogger().debug(`JsonSchemaHandler: skipped setting schema '${uri}'`)
366366
} else {
367-
existing.fileMatch.push(mapping.path)
367+
existing.fileMatch.push(path)
368368
}
369369
} else {
370370
settings.push({
371-
fileMatch: [mapping.path],
371+
fileMatch: [path],
372372
url: uri,
373373
})
374374
}
375375
} else {
376-
settings = filterJsonSettings(settings, file => file !== mapping.path)
376+
settings = filterJsonSettings(settings, file => file !== path)
377377
}
378378

379379
await this.config.update('json.schemas', settings)

src/shared/utilities/vsCodeUtils.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as vscode from 'vscode'
77
import * as nls from 'vscode-nls'
8-
8+
import * as pathutils from './pathUtils'
99
import { getLogger } from '../logger/logger'
1010
import { Timeout, waitTimeout } from './timeoutUtils'
1111

@@ -71,3 +71,15 @@ export async function activateExtension<T>(
7171
export function promisifyThenable<T>(thenable: Thenable<T>): Promise<T> {
7272
return new Promise((resolve, reject) => thenable.then(resolve, reject))
7373
}
74+
75+
export function isUntitledScheme(uri: vscode.Uri): boolean {
76+
return uri.scheme === 'untitled'
77+
}
78+
79+
// If the VSCode URI is not a file then return the string representation, otherwise normalize the filesystem path
80+
export function normalizeVSCodeUri(uri: vscode.Uri): string {
81+
if (uri.scheme !== 'file') {
82+
return uri.toString()
83+
}
84+
return pathutils.normalize(uri.fsPath)
85+
}

src/shared/watchedFiles.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as vscode from 'vscode'
77
import { getLogger } from './logger/logger'
88
import * as pathutils from './utilities/pathUtils'
99
import * as path from 'path'
10+
import { isUntitledScheme, normalizeVSCodeUri } from './utilities/vsCodeUtils'
1011

1112
export interface WatchedItem<T> {
1213
/**
@@ -22,7 +23,7 @@ export interface WatchedItem<T> {
2223
/**
2324
* WatchedFiles lets us index files in the current registry. It is used
2425
* for CFN templates among other things. WatchedFiles holds a list of pairs of
25-
* the absolute path to the file along with a transform of it that is useful for
26+
* the absolute path to the file or "untitled:" URI along with a transform of it that is useful for
2627
* where it is used. For example, for templates, it parses the template and stores it.
2728
*/
2829
export abstract class WatchedFiles<T> implements vscode.Disposable {
@@ -33,10 +34,13 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
3334
private readonly registryData: Map<string, T> = new Map<string, T>()
3435

3536
/**
36-
* Load in filesystem items, doing any parsing/validaton as required. If it fails, throws
37-
* @param path A string with the absolute path to the detected file
37+
* Process any incoming URI/content, doing any parsing/validation as required.
38+
* If the path does not point to a file on the local file system then contents should be defined.
39+
* If it fails, throws
40+
* @param path A uri with the absolute path to the detected file
3841
*/
39-
protected abstract load(path: string): Promise<T | undefined>
42+
protected abstract process(path: vscode.Uri, contents?: string): Promise<T | undefined>
43+
4044
/**
4145
* Name for logs
4246
*/
@@ -93,6 +97,25 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
9397
await this.rebuild()
9498
}
9599

100+
/**
101+
* Create a special watcher that operates only on untitled files.
102+
* To "watch" the in-memory contents of an untitled:/ file we just subscribe to `onDidChangeTextDocument`
103+
*/
104+
public async watchUntitledFiles() {
105+
this.disposables.push(
106+
vscode.workspace.onDidChangeTextDocument((event: vscode.TextDocumentChangeEvent) => {
107+
if (isUntitledScheme(event.document.uri)) {
108+
this.addItemToRegistry(event.document.uri, true, event.document.getText())
109+
}
110+
}),
111+
vscode.workspace.onDidCloseTextDocument((event: vscode.TextDocument) => {
112+
if (isUntitledScheme(event.uri)) {
113+
this.remove(event.uri)
114+
}
115+
})
116+
)
117+
}
118+
96119
/**
97120
* Adds a regex pattern to ignore paths containing the pattern
98121
*/
@@ -109,16 +132,16 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
109132
* Adds an item to registry. Wipes any existing item in its place with new copy of the data
110133
* @param uri vscode.Uri containing the item to load in
111134
*/
112-
public async addItemToRegistry(uri: vscode.Uri, quiet?: boolean): Promise<void> {
135+
public async addItemToRegistry(uri: vscode.Uri, quiet?: boolean, contents?: string): Promise<void> {
113136
const excluded = this.excludedFilePatterns.find(pattern => uri.fsPath.match(pattern))
114137
if (excluded) {
115138
getLogger().verbose(`${this.name}: excluding path (matches "${excluded}"): ${uri.fsPath}`)
116139
return
117140
}
118-
const pathAsString = pathutils.normalize(uri.fsPath)
119-
this.assertAbsolute(pathAsString)
141+
this.assertAbsolute(uri)
142+
const pathAsString = normalizeVSCodeUri(uri)
120143
try {
121-
const item = await this.load(pathAsString)
144+
const item = await this.process(uri, contents)
122145
if (item) {
123146
this.registryData.set(pathAsString, item)
124147
} else {
@@ -135,12 +158,12 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
135158

136159
/**
137160
* Get a specific item's data
161+
* Untitled files must be referred to by their URI
138162
* @param path Absolute path to item of interest or a vscode.Uri to the item
139163
*/
140164
public getRegisteredItem(path: string | vscode.Uri): WatchedItem<T> | undefined {
141165
// fsPath is needed for Windows, it's equivalent to path on mac/linux
142-
const absolutePath = typeof path === 'string' ? path : path.fsPath
143-
const normalizedPath = pathutils.normalize(absolutePath)
166+
const normalizedPath = typeof path === 'string' ? pathutils.normalize(path) : normalizeVSCodeUri(path)
144167
this.assertAbsolute(normalizedPath)
145168
const item = this.registryData.get(normalizedPath)
146169
if (!item) {
@@ -176,7 +199,7 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
176199
if (typeof path === 'string') {
177200
this.registryData.delete(path)
178201
} else {
179-
const pathAsString = pathutils.normalize(path.fsPath)
202+
const pathAsString = normalizeVSCodeUri(path)
180203
this.assertAbsolute(pathAsString)
181204
this.registryData.delete(pathAsString)
182205
}
@@ -240,16 +263,25 @@ export abstract class WatchedFiles<T> implements vscode.Disposable {
240263
)
241264
}
242265

243-
private assertAbsolute(p: string) {
244-
if (!path.isAbsolute(p)) {
245-
throw Error(`FileRegistry: path is relative when it should be absolute: ${p}`)
266+
/**
267+
* Assert if the path is absolute.
268+
* Untitled URIs are considered absolute
269+
* @param p The path to verify
270+
*/
271+
private assertAbsolute(p: string | vscode.Uri) {
272+
const pathAsString = typeof p === 'string' ? p : p.fsPath
273+
if (
274+
(typeof p === 'string' && !path.isAbsolute(pathAsString) && !pathAsString.startsWith('untitled:')) ||
275+
(typeof p !== 'string' && !path.isAbsolute(pathAsString) && !isUntitledScheme(p))
276+
) {
277+
throw new Error(`FileRegistry: path is relative when it should be absolute: ${pathAsString}`)
246278
}
247279
}
248280
}
249281

250282
export class NoopWatcher extends WatchedFiles<any> {
251-
protected async load(path: string): Promise<any> {
252-
throw new Error(`Attempted to add a file to the NoopWatcher: ${path}`)
283+
protected async process(uri: vscode.Uri): Promise<any> {
284+
throw new Error(`Attempted to add a file to the NoopWatcher: ${uri.fsPath}`)
253285
}
254286
protected name: string = 'NoOp'
255287
}

0 commit comments

Comments
 (0)