@gesslar/toolkit 0.0.13 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/toolkit",
3
- "version": "0.0.13",
3
+ "version": "0.1.1",
4
4
  "description": "Get in, bitches, we're going toolkitting.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -21,18 +21,11 @@
21
21
  },
22
22
  "scripts": {
23
23
  "lint": "eslint src/",
24
- "lint:docs": "cd docs && npm run lint",
25
24
  "lint:fix": "eslint src/ --fix",
26
- "lint:fix:docs": "cd docs && npm run lint:fix",
27
25
  "submit": "npm publish --access public",
28
26
  "update": "npx npm-check-updates -u && npm install",
29
- "test": "node examples/FileSystem/index.js",
30
- "docs:dev": "npm run docs:build && cd docs && npm run start",
31
- "docs:build": "npm run docs:clean && npm run docs:generate && cd docs && npm run build",
32
- "docs:serve": "cd docs && npm run serve",
33
- "docs:clean": "cd docs && npm run docs:clean",
34
- "docs:generate": "npx typedoc src/types/index.d.ts --out docs/docs/api --plugin typedoc-plugin-markdown --readme none --excludePrivate --excludeProtected --excludeInternal --includeVersion",
35
- "docs:install": "cd docs && npm install"
27
+ "test": "node --test tests/unit/*.test.js",
28
+ "test:unit": "node --test tests/unit/*.test.js"
36
29
  },
37
30
  "repository": {
38
31
  "type": "git",
@@ -66,8 +59,6 @@
66
59
  "@typescript-eslint/eslint-plugin": "^8.44.0",
67
60
  "@typescript-eslint/parser": "^8.44.0",
68
61
  "eslint": "^9.36.0",
69
- "eslint-plugin-jsdoc": "^60.1.0",
70
- "typedoc": "^0.28.13",
71
- "typedoc-plugin-markdown": "^4.9.0"
62
+ "eslint-plugin-jsdoc": "^60.1.0"
72
63
  }
73
64
  }
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Core file system abstractions
2
2
  export {default as FileObject} from "./lib/FileObject.js"
3
3
  export {default as DirectoryObject} from "./lib/DirectoryObject.js"
4
- export {default as FS} from "./lib/FS.js"
4
+ export {default as FS, fdType, upperFdTypes, fdTypes} from "./lib/FS.js"
5
5
 
6
6
  // Utility classes
7
7
  export {default as Cache} from "./lib/Cache.js"
@@ -9,6 +9,6 @@ export {default as Data} from "./lib/Data.js"
9
9
  export {default as Glog} from "./lib/Glog.js"
10
10
  export {default as Sass} from "./lib/Sass.js"
11
11
  export {default as Term} from "./lib/Term.js"
12
- export {default as Type} from "./lib/Type.js"
12
+ export {default as Type} from "./lib/TypeSpec.js"
13
13
  export {default as Util} from "./lib/Util.js"
14
14
  export {default as Valid} from "./lib/Valid.js"
package/src/lib/Cache.js CHANGED
@@ -1,4 +1,3 @@
1
- import File from "./FS.js"
2
1
  import FileObject from "./FileObject.js"
3
2
  import Sass from "./Sass.js"
4
3
 
@@ -41,11 +40,11 @@ export default class Cache {
41
40
  * parallel processing.
42
41
  *
43
42
  * @param {FileObject} fileObject - The file object to load and cache
44
- * @returns {Promise<object>} The parsed file data (JSON5 or YAML)
43
+ * @returns {Promise<unknown>} The parsed file data (JSON5 or YAML)
45
44
  * @throws {Sass} If the file cannot be found or accessed
46
45
  */
47
46
  async loadCachedData(fileObject) {
48
- const lastModified = await File.fileModified(fileObject)
47
+ const lastModified = await fileObject.modified()
49
48
 
50
49
  if(lastModified === null)
51
50
  throw Sass.new(`Unable to find file '${fileObject.path}'`)
@@ -64,7 +63,7 @@ export default class Cache {
64
63
  }
65
64
  }
66
65
 
67
- const data = await File.loadDataFile(fileObject)
66
+ const data = await fileObject.loadData()
68
67
 
69
68
  this.#modifiedTimes.set(fileObject.path, lastModified)
70
69
  this.#dataCache.set(fileObject.path, data)
package/src/lib/Data.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import Sass from "./Sass.js"
10
- import TypeSpec from "./Type.js"
10
+ import TypeSpec from "./TypeSpec.js"
11
11
  import Valid from "./Valid.js"
12
12
 
13
13
  export default class Data {
@@ -20,6 +20,7 @@ export default class Data {
20
20
  static primitives = Object.freeze([
21
21
  // Primitives
22
22
  "undefined",
23
+ "null",
23
24
  "boolean",
24
25
  "number",
25
26
  "bigint",
@@ -196,10 +197,18 @@ export default class Data {
196
197
  const result = {}
197
198
 
198
199
  for(const [key, value] of Object.entries(obj)) {
199
- if(Data.isType(value, "object"))
200
+ if(Data.isType(value, "array")) {
201
+ // Clone arrays by mapping over them
202
+ result[key] = value.map(item =>
203
+ Data.isType(item, "object") || Data.isType(item, "array")
204
+ ? Data.cloneObject(item)
205
+ : item
206
+ )
207
+ } else if(Data.isType(value, "object")) {
200
208
  result[key] = Data.cloneObject(value)
201
- else
209
+ } else {
202
210
  result[key] = value
211
+ }
203
212
  }
204
213
 
205
214
  return freeze ? Object.freeze(result) : result
@@ -340,30 +349,16 @@ export default class Data {
340
349
  return false
341
350
 
342
351
  const valueType = Data.typeOf(value)
352
+ const normalizedType = type.toLowerCase()
343
353
 
344
- switch(type.toLowerCase()) {
345
- case "array":
346
- return Array.isArray(value) // Native array check
347
- case "string":
348
- return valueType === "string"
349
- case "boolean":
350
- return valueType === "boolean"
354
+ // Special cases that need extra validation
355
+ switch(normalizedType) {
351
356
  case "number":
352
357
  return valueType === "number" && !isNaN(value) // Excludes NaN
353
358
  case "object":
354
- return value !== null && valueType === "object" && !Array.isArray(value) // Excludes arrays and null
355
- case "function":
356
- return valueType === "function"
357
- case "symbol":
358
- return valueType === "symbol" // ES6 Symbol type
359
- case "bigint":
360
- return valueType === "bigint" // BigInt support
361
- case "null":
362
- return value === null // Explicit null check
363
- case "undefined":
364
- return valueType === "undefined" // Explicit undefined check
359
+ return valueType === "object" && value !== null && !Array.isArray(value) // Excludes arrays and null
365
360
  default:
366
- return false // Unknown type
361
+ return valueType === normalizedType
367
362
  }
368
363
  }
369
364
 
@@ -374,7 +369,13 @@ export default class Data {
374
369
  * @returns {string} The type of the value
375
370
  */
376
371
  static typeOf(value) {
377
- return Array.isArray(value) ? "array" : typeof value
372
+ if(value === null)
373
+ return "null"
374
+
375
+ if(Array.isArray(value))
376
+ return "array"
377
+
378
+ return typeof value
378
379
  }
379
380
 
380
381
  /**
@@ -397,11 +398,16 @@ export default class Data {
397
398
  * @returns {boolean} Whether the value is empty
398
399
  */
399
400
  static isEmpty(value, checkForNothing = true) {
400
- const type = Data.typeOf(value)
401
-
402
401
  if(checkForNothing && Data.isNothing(value))
403
402
  return true
404
403
 
404
+ // When checkForNothing is false, null/undefined should not be treated as empty
405
+ // They should be processed like regular values
406
+ if(!checkForNothing && Data.isNothing(value))
407
+ return false
408
+
409
+ const type = Data.typeOf(value)
410
+
405
411
  if(!Data.emptyableTypes.includes(type))
406
412
  return false
407
413
 
@@ -409,6 +415,7 @@ export default class Data {
409
415
  case "array":
410
416
  return value.length === 0
411
417
  case "object":
418
+ // null was already handled above, so this should only be real objects
412
419
  return Object.keys(value).length === 0
413
420
  case "string":
414
421
  return value.trim().length === 0
@@ -48,7 +48,6 @@ export default class DirectoryObject extends FS {
48
48
  extension: null,
49
49
  isFile: false,
50
50
  isDirectory: true,
51
- directory: null,
52
51
  })
53
52
 
54
53
  /**
@@ -57,7 +56,7 @@ export default class DirectoryObject extends FS {
57
56
  * @param {string} directory - The directory path
58
57
  */
59
58
  constructor(directory) {
60
- super(directory)
59
+ super()
61
60
 
62
61
  const fixedDir = FS.fixSlashes(directory ?? ".")
63
62
  const absolutePath = path.resolve(fixedDir)
@@ -214,10 +213,14 @@ export default class DirectoryObject extends FS {
214
213
  * @returns {Promise<{files: Array<FileObject>, directories: Array<DirectoryObject>}>} The files and directories in the directory.
215
214
  */
216
215
  async read(directory) {
217
- const found = await fs.readdir(directory.uri, {withFileTypes: true})
216
+ const found = await fs.readdir(
217
+ new URL(directory.uri),
218
+ {withFileTypes: true}
219
+ )
220
+
218
221
  const results = await Promise.all(
219
222
  found.map(async dirent => {
220
- const fullPath = path.join(directory.uri, dirent.name)
223
+ const fullPath = path.join(directory.path, dirent.name)
221
224
  const stat = await fs.stat(fullPath)
222
225
 
223
226
  return {dirent, stat, fullPath}
package/src/lib/FS.js CHANGED
@@ -8,6 +8,12 @@ import FileObject from "./FileObject.js"
8
8
  import Sass from "./Sass.js"
9
9
  import Valid from "./Valid.js"
10
10
 
11
+ const fdTypes = Object.freeze(["file", "directory"])
12
+ const upperFdTypes = Object.freeze(fdTypes.map(type => type.toUpperCase()))
13
+ const fdType = Object.freeze(await Data.allocateObject(upperFdTypes, fdTypes))
14
+
15
+ export {fdType, upperFdTypes, fdTypes}
16
+
11
17
  export default class FS {
12
18
  /**
13
19
  * Fix slashes in a path
@@ -123,6 +129,7 @@ export default class FS {
123
129
  * @returns {string} The merged path
124
130
  */
125
131
  static mergeOverlappingPaths(path1, path2, sep=path.sep) {
132
+ const isAbsolutePath1 = path.isAbsolute(path1)
126
133
  const from = path1.split(sep).filter(Boolean)
127
134
  const to = path2.split(sep).filter(Boolean)
128
135
 
@@ -131,18 +138,17 @@ export default class FS {
131
138
  return path1
132
139
  }
133
140
 
134
- const overlapIndex = from.findLastIndex((curr, index) => {
135
- const left = from.at(index)
136
- const right = to.at(0)
137
-
138
- return left === right
139
- })
141
+ const overlapIndex = from.findLastIndex(curr => curr === to.at(0))
140
142
 
141
143
  // If overlap is found, slice and join
142
144
  if(overlapIndex !== -1) {
143
145
  const prefix = from.slice(0, overlapIndex)
146
+ const result = path.join(...prefix, ...to)
144
147
 
145
- return path.join(...prefix, ...to)
148
+ // If original path1 was absolute, ensure result is also absolute
149
+ return isAbsolutePath1 && !path.isAbsolute(result)
150
+ ? path.sep + result
151
+ : result
146
152
  }
147
153
 
148
154
  // If no overlap, just join the paths
@@ -159,8 +165,8 @@ export default class FS {
159
165
  */
160
166
  static resolvePath(fromPath, toPath) {
161
167
  // Normalize inputs
162
- const from = fromPath.trim()
163
- const to = toPath.trim()
168
+ const from = fromPath?.trim() ?? ""
169
+ const to = toPath?.trim() ?? ""
164
170
 
165
171
  // Handle empty cases
166
172
  if(!from && !to)
@@ -172,16 +178,20 @@ export default class FS {
172
178
  if(!to)
173
179
  return from
174
180
 
181
+ const normalizedTo = /^\.\//.test(to)
182
+ ? path.normalize(to)
183
+ : to
184
+
175
185
  // Strategy 1: If 'to' is absolute, it's standalone
176
- if(path.isAbsolute(to))
177
- return to
186
+ if(path.isAbsolute(normalizedTo))
187
+ return normalizedTo
178
188
 
179
- // Strategy 2: If 'to' contains relative navigation (./ or ../)
180
- if(to.includes("./") || to.includes("../") || to.startsWith(".") || to.startsWith(".."))
181
- return path.resolve(from, to)
189
+ // Strategy 2: If 'to' contains relative navigation
190
+ if(to.startsWith("../"))
191
+ return path.resolve(from, normalizedTo)
182
192
 
183
193
  // Strategy 3: Try overlap-based merging, which will default to a basic
184
194
  // join if no overlap
185
- return FS.mergeOverlappingPaths(from, to)
195
+ return FS.mergeOverlappingPaths(from, normalizedTo)
186
196
  }
187
197
  }
@@ -32,6 +32,19 @@ import Valid from "./Valid.js"
32
32
  */
33
33
 
34
34
  export default class FileObject extends FS {
35
+ /**
36
+ * Configuration mapping data types to their respective parser modules for loadData method.
37
+ * Each parser module must have a .parse() method that accepts a string and returns parsed data.
38
+ *
39
+ * @type {{[key: string]: Array<typeof JSON5 | typeof YAML>}}
40
+ */
41
+ static dataLoaderConfig = Object.freeze({
42
+ json5: [JSON5],
43
+ json: [JSON5],
44
+ yaml: [YAML],
45
+ any: [JSON5, YAML]
46
+ })
47
+
35
48
  /**
36
49
  * @type {object}
37
50
  * @private
@@ -64,18 +77,23 @@ export default class FileObject extends FS {
64
77
  * @param {DirectoryObject|string|null} [directory] - The parent directory (object or string)
65
78
  */
66
79
  constructor(fileName, directory=null) {
67
- super(fileName, directory)
80
+ super()
81
+
82
+ if(!fileName || typeof fileName !== "string" || fileName.length === 0) {
83
+ throw Sass.new("fileName must be a non-empty string")
84
+ }
68
85
 
69
86
  const fixedFile = FS.fixSlashes(fileName)
70
87
 
71
88
  const {dir,base,ext} = this.#deconstructFilenameToParts(fixedFile)
72
89
 
73
- if(!directory)
90
+ if(!directory) {
74
91
  directory = new DirectoryObject(dir)
92
+ } else if(typeof directory === "string") {
93
+ directory = new DirectoryObject(directory)
94
+ }
75
95
 
76
- const final = path.isAbsolute(fixedFile)
77
- ? fixedFile
78
- : path.resolve(directory?.path ?? ".", fixedFile)
96
+ const final = FS.resolvePath(directory?.path ?? ".", fixedFile)
79
97
 
80
98
  const resolved = final
81
99
  const fileUri = FS.pathToUri(resolved)
@@ -99,6 +117,9 @@ export default class FileObject extends FS {
99
117
  *
100
118
  * @returns {string} string representation of the FileObject
101
119
  */
120
+ toString() {
121
+ return `[FileObject: ${this.path}]`
122
+ }
102
123
 
103
124
  /**
104
125
  * Returns a JSON representation of the FileObject.
@@ -360,7 +381,7 @@ export default class FileObject extends FS {
360
381
  *
361
382
  * @param {string} [type] - The expected type of data to parse.
362
383
  * @param {string} [encoding] - The encoding to read the file as.
363
- * @returns {object} The parsed data object.
384
+ * @returns {Promise<unknown>} The parsed data object.
364
385
  */
365
386
  async loadData(type="any", encoding="utf8") {
366
387
  const content = await this.read(encoding)
@@ -371,7 +392,11 @@ export default class FileObject extends FS {
371
392
  any: [JSON5,YAML]
372
393
  }[type.toLowerCase()]
373
394
 
374
- for(const [format] of toTry) {
395
+ if(!toTry) {
396
+ throw Sass.new(`Unsupported data type '${type}'. Supported types: json, json5, yaml, any`)
397
+ }
398
+
399
+ for(const format of toTry) {
375
400
  try {
376
401
  const result = format.parse(content)
377
402
 
package/src/lib/Glog.js CHANGED
@@ -21,9 +21,9 @@ import console from "node:console"
21
21
  */
22
22
  class Glog {
23
23
  /** @type {number} Current log level threshold (0-5) */
24
- logLevel = 0
24
+ static logLevel = 0
25
25
  /** @type {string} Prefix to prepend to all log messages */
26
- logPrefix = ""
26
+ static logPrefix = ""
27
27
 
28
28
  /**
29
29
  * Sets the log prefix for all subsequent log messages.
@@ -39,7 +39,7 @@ class Glog {
39
39
  static setLogPrefix(prefix) {
40
40
  this.logPrefix = prefix
41
41
 
42
- return Glog
42
+ return this
43
43
  }
44
44
 
45
45
  /**
@@ -57,7 +57,7 @@ class Glog {
57
57
  static setLogLevel(level) {
58
58
  this.logLevel = Data.clamp(level, 0, 5)
59
59
 
60
- return Glog
60
+ return this
61
61
  }
62
62
 
63
63
  /**
@@ -75,11 +75,11 @@ class Glog {
75
75
  let level, rest
76
76
 
77
77
  if(args.length === 0) {
78
- ;[level=0, rest=[""]] = null
78
+ ;[level=0, rest=[""]] = []
79
79
  } else if(args.length === 1) {
80
80
  ;[rest, level=0] = [args, 0]
81
81
  } else {
82
- ;[level, ...rest] = args
82
+ ;[level, ...rest] = typeof args[0] === "number" ? args : [0, ...args]
83
83
  }
84
84
 
85
85
  if(level > this.logLevel)
@@ -123,10 +123,18 @@ class Glog {
123
123
  // Wrap the class in a proxy
124
124
  export default new Proxy(Glog, {
125
125
  apply(target, thisArg, argumentsList) {
126
- // When called as function: MyClass(things, and, stuff)
126
+ // When called as function: call execute method internally
127
127
  return target.execute(...argumentsList)
128
128
  },
129
129
  construct(target, argumentsList) {
130
130
  return new target(...argumentsList)
131
+ },
132
+ get(target, prop) {
133
+ // Hide execute method from public API
134
+ if(prop === "execute") {
135
+ return undefined
136
+ }
137
+
138
+ return target[prop]
131
139
  }
132
140
  })
@@ -156,20 +156,33 @@ export default class TypeSpec {
156
156
  const matchingTypeSpec = this.filter(spec => {
157
157
  const {typeName: allowedType, array: allowedArray} = spec
158
158
 
159
- if(valueType === allowedType && !isArray && !allowedArray)
160
- return !allowEmpty ? !empty : true
159
+ // Handle non-array values
160
+ if(!isArray && !allowedArray) {
161
+ if(valueType === allowedType)
162
+ return allowEmpty || !empty
161
163
 
164
+ return false
165
+ }
166
+
167
+ // Handle array values
162
168
  if(isArray) {
163
- if(allowedType === "array")
164
- if(!allowedArray)
165
- return true
169
+ // Special case for generic "array" type
170
+ if(allowedType === "array" && !allowedArray)
171
+ return allowEmpty || !empty
172
+
173
+ // Must be an array type specification
174
+ if(!allowedArray)
175
+ return false
166
176
 
177
+ // Handle empty arrays
167
178
  if(empty)
168
- if(allowEmpty)
169
- return true
179
+ return allowEmpty
170
180
 
181
+ // Check if array elements match the required type
171
182
  return Data.isArrayUniform(value, allowedType)
172
183
  }
184
+
185
+ return false
173
186
  })
174
187
 
175
188
  return matchingTypeSpec.length > 0
package/src/lib/Util.js CHANGED
@@ -98,7 +98,7 @@ export default class Util {
98
98
  .reduce((acc, curr) => acc.sign === "--" ? acc : curr, {})
99
99
  ?.option
100
100
  })
101
- .filter(Boolean)
101
+ .filter(option => option && /^[a-zA-Z0-9]/.test(option)) // Filter out options that don't start with alphanumeric
102
102
  }
103
103
 
104
104
  /**
@@ -184,6 +184,11 @@ export default class Util {
184
184
  } catch(error) {
185
185
  const argsDesc = args.length > 0 ? `with arguments: ${args.map(String).join(", ")}` : "with no arguments"
186
186
 
187
+ // If it's already a Sass error, just re-throw to avoid double-wrapping
188
+ if(error instanceof Sass) {
189
+ throw error
190
+ }
191
+
187
192
  throw Sass.new(
188
193
  `Processing '${event}' event ${argsDesc}.`,
189
194
  error
@@ -214,6 +219,11 @@ export default class Util {
214
219
  } catch(error) {
215
220
  const argsDesc = args.length > 0 ? `with arguments: ${args.map(String).join(", ")}` : "with no arguments"
216
221
 
222
+ // If it's already a Sass error, just re-throw to avoid double-wrapping
223
+ if(error instanceof Sass) {
224
+ throw error
225
+ }
226
+
217
227
  throw Sass.new(
218
228
  `Processing '${event}' event ${argsDesc}.`,
219
229
  error
@@ -24,7 +24,7 @@ declare class Cache {
24
24
  * @returns The parsed file data (JSON5 or YAML)
25
25
  * @throws If the file cannot be found or accessed
26
26
  */
27
- loadCachedData(fileObject: FileObject): Promise<object>
27
+ loadCachedData(fileObject: FileObject): Promise<unknown>
28
28
  }
29
29
 
30
30
  export default Cache
package/src/types/FS.d.ts CHANGED
@@ -29,3 +29,18 @@ export default class FS {
29
29
  /** Resolve a path relative to another path using various strategies. Handles absolute paths, relative navigation, and overlap-based merging */
30
30
  static resolvePath(fromPath: string, toPath: string): string
31
31
  }
32
+
33
+ /**
34
+ * File descriptor types as lowercase strings
35
+ */
36
+ export const fdTypes: readonly ["file", "directory"]
37
+
38
+ /**
39
+ * File descriptor types as uppercase strings
40
+ */
41
+ export const upperFdTypes: readonly ["FILE", "DIRECTORY"]
42
+
43
+ /**
44
+ * Mapping from uppercase file descriptor types to lowercase
45
+ */
46
+ export const fdType: Readonly<Record<"FILE" | "DIRECTORY", "file" | "directory">>
@@ -3,6 +3,14 @@
3
3
  import DirectoryObject from './DirectoryObject.js'
4
4
  import FS from './FS.js'
5
5
 
6
+ /**
7
+ * Configuration for data loading parsers in loadData method.
8
+ * Maps supported data types to their respective parser functions.
9
+ */
10
+ export interface DataLoaderConfig {
11
+ [type: string]: Array<{ parse: (content: string) => unknown }>
12
+ }
13
+
6
14
  /**
7
15
  * FileObject encapsulates metadata and operations for a file, providing intelligent
8
16
  * path resolution, metadata extraction, and file system operations. This class serves
@@ -101,6 +109,12 @@ import FS from './FS.js'
101
109
  * file operations.
102
110
  */
103
111
  export default class FileObject extends FS {
112
+ /**
113
+ * Configuration for data parsing in the loadData method.
114
+ * Maps data type names to arrays of parser functions.
115
+ */
116
+ static readonly dataLoaderConfig: DataLoaderConfig
117
+
104
118
  /**
105
119
  * Create a new FileObject instance with intelligent path resolution.
106
120
  *
@@ -311,5 +325,5 @@ export default class FileObject extends FS {
311
325
  write(content: string, encoding?: string): Promise<void>
312
326
 
313
327
  /** Load an object from JSON5 or YAML file with type specification */
314
- loadData(type?: 'json' | 'json5' | 'yaml' | 'any', encoding?: string): Promise<any>
328
+ loadData(type?: 'json' | 'json5' | 'yaml' | 'any', encoding?: string): Promise<unknown>
315
329
  }
@@ -2,7 +2,7 @@
2
2
  // Core file system abstractions
3
3
  export { default as FileObject } from './FileObject.js'
4
4
  export { default as DirectoryObject } from './DirectoryObject.js'
5
- export { default as FS } from './FS.js'
5
+ export { default as FS, fdType, upperFdTypes, fdTypes } from './FS.js'
6
6
 
7
7
  // Utility classes
8
8
  export { default as Cache } from './Cache.js'