@nette/neon 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../dist/cli.js';
3
+
4
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,28 @@
1
+ import type { ArrayItemNode, ValueNode } from './nodes.ts';
2
+ /** Where a built node will stand. */
3
+ export interface Place {
4
+ /** Write arrays as blocks. */
5
+ block: boolean;
6
+ /** Indentation of the line of the item that will hold the value. */
7
+ indent: string;
8
+ /** Indentation unit of nested blocks. */
9
+ unit: string;
10
+ /** Line ending of the document. */
11
+ eol: string;
12
+ /** Write multiline strings in """, because a ''' would close an earlier string of the document. */
13
+ doubleQuoted?: boolean;
14
+ }
15
+ /**
16
+ * Makes nodes from values of the host. The text is what encode() writes, so new values look the
17
+ * same as encoded ones; it is parsed, so the nodes are valid by construction.
18
+ */
19
+ export declare class Builder {
20
+ /** A value as a node, without edge trivia; a non-empty array is a block if the place allows it. */
21
+ static value(value: unknown, place: Place): ValueNode;
22
+ /** An item of a block, `key: value` or `- value`, with its indentation and line ending. */
23
+ static blockItem(key: string | null, value: unknown, place: Place): ArrayItemNode;
24
+ /** @internal The value of a document made of the text, detached, its tokens without an origin. */
25
+ static parse(text: string): ValueNode;
26
+ /** The text of a key: an integer key as digits, anything else as a string. */
27
+ static keyText(key: string): string;
28
+ }
@@ -0,0 +1,76 @@
1
+ import { encode } from "./encoder.js";
2
+ import { forgetOrigin, setItems, setLeading, setSlots, setTrailing } from "./internal.js";
3
+ import { parse } from "./parser.js";
4
+ import { NeonError } from "./errors.js";
5
+ import { isIntegerKey } from "./values.js";
6
+ /**
7
+ * Makes nodes from values of the host. The text is what encode() writes, so new values look the
8
+ * same as encoded ones; it is parsed, so the nodes are valid by construction.
9
+ */
10
+ export class Builder {
11
+ /** A value as a node, without edge trivia; a non-empty array is a block if the place allows it. */
12
+ static value(value, place) {
13
+ const block = place.block && isCollection(value) && hasItems(value);
14
+ let text = encode(value, { blockMode: block, indentation: place.unit, doubleQuotedMultiline: place.doubleQuoted });
15
+ text = block
16
+ ? indent(text.replace(/\n+$/, ''), place.indent + place.unit, true)
17
+ : indent(text, place.indent, false);
18
+ const node = Builder.parse(text.replaceAll('\n', place.eol));
19
+ if (!block) {
20
+ node.getFirstToken()[setLeading]([]);
21
+ }
22
+ node.getLastToken()[setTrailing]([]);
23
+ return node;
24
+ }
25
+ /** An item of a block, `key: value` or `- value`, with its indentation and line ending. */
26
+ static blockItem(key, value, place) {
27
+ const head = key === null ? '-' : Builder.keyText(key) + ':';
28
+ let text;
29
+ if (value === null && key !== null) {
30
+ text = head;
31
+ }
32
+ else if (isCollection(value) && hasItems(value)) {
33
+ const body = encode(value, { blockMode: true, indentation: place.unit, doubleQuotedMultiline: place.doubleQuoted }).replace(/\n+$/, '');
34
+ text = head + '\n' + indent(body, place.unit, true);
35
+ }
36
+ else {
37
+ text = head + ' ' + encode(value, { indentation: place.unit, doubleQuotedMultiline: place.doubleQuoted });
38
+ }
39
+ const block = Builder.parse(indent(text, place.indent, true).replaceAll('\n', place.eol) + place.eol);
40
+ const item = block.items[0];
41
+ block[setItems]([]);
42
+ return item;
43
+ }
44
+ /** @internal The value of a document made of the text, detached, its tokens without an origin. */
45
+ static parse(text) {
46
+ const document = parse(text);
47
+ const node = document.value;
48
+ document[setSlots]({ value: null });
49
+ for (const token of node.getTokens()) {
50
+ token[forgetOrigin]();
51
+ }
52
+ return node;
53
+ }
54
+ /** The text of a key: an integer key as digits, anything else as a string. */
55
+ static keyText(key) {
56
+ if (key.includes('\n')) {
57
+ throw new NeonError('A key cannot contain a line break.');
58
+ }
59
+ return isIntegerKey(key) ? key : encode(key);
60
+ }
61
+ }
62
+ /** A non-empty array, Map or plain object. */
63
+ function hasItems(value) {
64
+ return value instanceof Map ? value.size > 0 : Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0;
65
+ }
66
+ function isCollection(value) {
67
+ if (value instanceof Map || Array.isArray(value)) {
68
+ return true;
69
+ }
70
+ const proto = typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined;
71
+ return proto === Object.prototype || proto === null;
72
+ }
73
+ /** Prefixes non-empty lines; the first one only if asked. */
74
+ function indent(text, prefix, first) {
75
+ return text.split('\n').map((line, i) => line === '' || (i === 0 && !first) ? line : prefix + line).join('\n');
76
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface Io {
2
+ out(text: string): void;
3
+ err(text: string): void;
4
+ stdin(): Uint8Array;
5
+ }
6
+ /** Runs the linter and returns the exit code. */
7
+ export declare function main(args: string[], io?: Io): number;
package/dist/cli.js ADDED
@@ -0,0 +1,172 @@
1
+ // The command line linter. Uses Node; the rest of the package does not.
2
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { NeonError } from "./errors.js";
4
+ import { parse } from "./parser.js";
5
+ import { Cursor } from "./position.js";
6
+ import { decodeUtf8 } from "./utf8.js";
7
+ const Usage = `Usage: neon-lint [options] <path>...
8
+
9
+ Checks NEON files; a path is a file, a directory (searched for *.neon) or - for stdin.
10
+
11
+ Options:
12
+ --check also verify that printing the parsed tree gives back the file
13
+ --json print the findings as JSON
14
+ --quiet print nothing, only set the exit code
15
+ --verbose print the number of files when all are valid
16
+ `;
17
+ const SkippedDirectories = new Set(['vendor', 'node_modules', '.git']);
18
+ /** Runs the linter and returns the exit code. */
19
+ export function main(args, io = nodeIo) {
20
+ const options = new Set(args.filter((arg) => arg.startsWith('--')));
21
+ const paths = args.filter((arg) => !arg.startsWith('--'));
22
+ const known = ['--check', '--json', '--quiet', '--verbose', '--help'];
23
+ const unknown = [...options].find((option) => !known.includes(option));
24
+ if (options.has('--help')) {
25
+ io.out(Usage);
26
+ return 0;
27
+ }
28
+ else if (unknown || !paths.length) {
29
+ io.err((unknown ? `Unknown option ${unknown}\n\n` : '') + Usage);
30
+ return 2;
31
+ }
32
+ const { files, findings } = collect(paths);
33
+ if (!files.length && !findings.length) {
34
+ findings.push({ path: paths.join(' '), line: null, column: null, message: 'No file found' });
35
+ }
36
+ for (const file of files) {
37
+ try {
38
+ const finding = lintFile(file, options.has('--check'), io);
39
+ if (finding) {
40
+ findings.push(finding);
41
+ }
42
+ }
43
+ catch (e) {
44
+ return internalError(file, e, io);
45
+ }
46
+ }
47
+ findings.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
48
+ if (options.has('--quiet')) {
49
+ // nothing
50
+ }
51
+ else if (options.has('--json')) {
52
+ io.out(JSON.stringify(findings) + '\n');
53
+ }
54
+ else if (findings.length) {
55
+ io.out(findings.map(formatFinding).join('\n') + '\n');
56
+ }
57
+ else if (options.has('--verbose')) {
58
+ io.out(`OK ${files.length} files\n`);
59
+ }
60
+ return findings.length ? 1 : 0;
61
+ }
62
+ function formatFinding(f) {
63
+ return f.line === null ? `${f.path}: ${f.message}` : `${f.path}:${f.line}:${f.column}: ${f.message}`;
64
+ }
65
+ function internalError(path, e, io) {
66
+ io.err(`Internal error in ${path}: ${e instanceof Error ? e.message : e}\nPlease report it at https://github.com/nette/neon-js/issues\n`);
67
+ return 2;
68
+ }
69
+ /** The text of a file, or the finding why it cannot be read. */
70
+ function read(path, io) {
71
+ try {
72
+ return decodeUtf8(path === '-' ? io.stdin() : readFileSync(path));
73
+ }
74
+ catch (e) {
75
+ if (e instanceof NeonError) {
76
+ return { path, line: e.position.line, column: e.position.column, message: e.message };
77
+ }
78
+ return { path, line: null, column: null, message: e instanceof Error ? e.message : String(e) };
79
+ }
80
+ }
81
+ function lintFile(path, check, io) {
82
+ const input = read(path, io);
83
+ if (typeof input !== 'string') {
84
+ return input;
85
+ }
86
+ try {
87
+ const document = parse(input);
88
+ if (check) {
89
+ const printed = document.print();
90
+ if (printed !== input) {
91
+ let i = 0;
92
+ while (printed[i] === input[i]) {
93
+ i++;
94
+ }
95
+ const position = new Cursor(input).at(i);
96
+ return { path, line: position.line, column: position.column, message: 'Printing the tree differs from the file' };
97
+ }
98
+ }
99
+ }
100
+ catch (e) {
101
+ if (e instanceof NeonError && e.position) {
102
+ return { path, line: e.position.line, column: e.position.column, message: e.message };
103
+ }
104
+ throw e;
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * The files to lint, sorted by path, each once even when two paths name it; a path given that does not
110
+ * exist, or a directory that cannot be read, is a finding. The recursion does not follow links, a path
111
+ * given is followed.
112
+ */
113
+ function collect(paths) {
114
+ const files = (new Map); // identity of the file => the path shown
115
+ const findings = [];
116
+ const add = (path) => {
117
+ let identity = path;
118
+ try {
119
+ identity = path === '-' ? path : realpathSync.native(path);
120
+ }
121
+ catch {
122
+ // the path itself
123
+ }
124
+ if (!files.has(identity)) {
125
+ files.set(identity, path);
126
+ }
127
+ };
128
+ const walk = (dir) => {
129
+ let entries;
130
+ try {
131
+ entries = readdirSync(dir);
132
+ }
133
+ catch (e) {
134
+ findings.push({ path: dir, line: null, column: null, message: e instanceof Error ? e.message : String(e) });
135
+ return;
136
+ }
137
+ for (const entry of entries) {
138
+ const full = dir.replace(/[\\/]+$/, '') + '/' + entry; // the same output on every system
139
+ const stat = lstatSync(full, { throwIfNoEntry: false });
140
+ if (!stat || stat.isSymbolicLink() || entry.startsWith('.') || SkippedDirectories.has(entry)) {
141
+ continue;
142
+ }
143
+ else if (stat.isDirectory()) {
144
+ walk(full);
145
+ }
146
+ else if (entry.endsWith('.neon')) {
147
+ add(full);
148
+ }
149
+ }
150
+ };
151
+ for (const path of paths) {
152
+ const stat = path === '-' ? null : statSync(path, { throwIfNoEntry: false });
153
+ if (path === '-' || stat?.isFile()) {
154
+ add(path);
155
+ }
156
+ else if (stat?.isDirectory()) {
157
+ walk(path);
158
+ }
159
+ else {
160
+ findings.push({ path, line: null, column: null, message: stat ? 'Not a file or a directory' : 'No file found' });
161
+ }
162
+ }
163
+ return {
164
+ files: [...files.values()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0),
165
+ findings,
166
+ };
167
+ }
168
+ const nodeIo = {
169
+ out: (text) => process.stdout.write(text),
170
+ err: (text) => process.stderr.write(text),
171
+ stdin: () => readFileSync(0),
172
+ };
@@ -0,0 +1,5 @@
1
+ import { Node } from './nodes.ts';
2
+ import type { Token } from './token.ts';
3
+ /** Text form of the tree, as in the conformance corpus. */
4
+ export declare function dump(node: Node): string;
5
+ export declare function dumpToken(token: Token): string;
package/dist/dumper.js ADDED
@@ -0,0 +1,75 @@
1
+ import { Node } from "./nodes.js";
2
+ /** Text form of the tree, as in the conformance corpus. */
3
+ export function dump(node) {
4
+ const lines = [];
5
+ const write = (node, prefix, depth) => {
6
+ const indent = '\t'.repeat(depth);
7
+ lines.push(indent + prefix + node.constructor.name);
8
+ for (const [slot, value] of slots(node)) {
9
+ if (value === null) {
10
+ continue;
11
+ }
12
+ else if (Array.isArray(value)) {
13
+ if (!value.length) {
14
+ lines.push(`${indent}\t${slot}: []`);
15
+ continue;
16
+ }
17
+ lines.push(`${indent}\t${slot}:`);
18
+ for (const item of value) {
19
+ write(item, '- ', depth + 2);
20
+ }
21
+ }
22
+ else if (value instanceof Node) {
23
+ write(value, slot + ': ', depth + 1);
24
+ }
25
+ else {
26
+ lines.push(`${indent}\t${slot}: ${dumpToken(value)}`);
27
+ }
28
+ }
29
+ };
30
+ write(node, '', 0);
31
+ return lines.join('\n') + '\n';
32
+ }
33
+ function slots(node) {
34
+ const n = node;
35
+ return (SlotNames[node.constructor.name] ?? []).map((name) => {
36
+ const value = n[name];
37
+ return [name, Array.isArray(value) ? [...value] : value];
38
+ });
39
+ }
40
+ const SlotNames = {
41
+ DocumentNode: ['value', 'endOfFile'],
42
+ BlockArrayNode: ['items'],
43
+ InlineArrayNode: ['openBracket', 'items', 'closeBracket'],
44
+ ArrayItemNode: ['bullet', 'key', 'colon', 'value', 'separator'],
45
+ EntityNode: ['value', 'attributes'],
46
+ EntityChainNode: ['entities'],
47
+ StringNode: ['token'],
48
+ LiteralNode: ['token'],
49
+ };
50
+ export function dumpToken(token) {
51
+ let res = `${token.kind} ${dumpText(token.text)}`;
52
+ if (token.leadingTrivia.length) {
53
+ res += ' <' + dumpTrivia(token.leadingTrivia);
54
+ }
55
+ if (token.trailingTrivia.length) {
56
+ res += ' >' + dumpTrivia(token.trailingTrivia);
57
+ }
58
+ return res;
59
+ }
60
+ function dumpTrivia(list) {
61
+ return '[' + list.map((trivia) => `${trivia.kind} ${dumpText(trivia.text)}`).join(', ') + ']';
62
+ }
63
+ /** JSON string escaping only quotes, backslashes and control characters. */
64
+ function dumpText(text) {
65
+ let res = '"';
66
+ for (const c of text) {
67
+ const code = c.charCodeAt(0);
68
+ res += ShortEscapes[c]
69
+ ?? (c === '"' || c === '\\' ? '\\' + c : code < 0x20 ? '\\u' + code.toString(16).padStart(4, '0') : c);
70
+ }
71
+ return res + '"';
72
+ }
73
+ const ShortEscapes = {
74
+ '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t',
75
+ };
@@ -0,0 +1,41 @@
1
+ import { ArrayItemNode, ArrayNode, DocumentNode, LiteralNode, type Node, StringNode, ValueNode } from './nodes.ts';
2
+ /** What happens to the comments a removed item owns: the one on its line and those directly above it; the comments inside its value go with it. */
3
+ export declare const CommentPolicy: {
4
+ /** The comments that describe the item go with it. */
5
+ readonly Drop: "drop";
6
+ /** The comments stay, above whatever follows. */
7
+ readonly MoveToNextToken: "move";
8
+ };
9
+ export type CommentPolicy = typeof CommentPolicy[keyof typeof CommentPolicy];
10
+ export type PathSegment = string | number | {
11
+ item: number;
12
+ };
13
+ /** Writes the value of an item; an equal value is a no-op, a changed one keeps the style where it can. */
14
+ export declare function setItemValue(item: ArrayItemNode, value: unknown): void;
15
+ /** Puts the node into the value slot or empties it, without the comparison and styling of setItemValue(). */
16
+ export declare function setItemNode(item: ArrayItemNode, node: ValueNode | null): void;
17
+ /** Adds an item to an array, at the end or before the item at the position. */
18
+ export declare function addItem(array: ArrayNode, key: string | null, value: unknown, position?: number): ArrayItemNode;
19
+ /**
20
+ * Inserts an item taken out of a tree, with its comments, reindented to its new place: the common
21
+ * prefix of the indentation of all its lines moves. The lines inside a multiline string are part
22
+ * of its token and its value, so they stay.
23
+ */
24
+ export declare function insertItem(array: ArrayNode, item: ArrayItemNode, at: number): void;
25
+ /** Removes the key at the path: the item found, and the items whose value it overwrote. */
26
+ export declare function removeByPath(document: DocumentNode, path: readonly PathSegment[], comments: CommentPolicy, nullable?: boolean, mergeBlankLines?: boolean): void;
27
+ /** Removes an item with the comments it owns. */
28
+ export declare function removeItem(item: ArrayItemNode, comments: CommentPolicy, nullable?: boolean, mergeBlankLines?: boolean): void;
29
+ /** Replaces the key of an item, in the quotes of the old one when it can be written in them; or by a key node. */
30
+ export declare function setItemKey(item: ArrayItemNode, key: string | StringNode | LiteralNode): void;
31
+ /** Puts the node into the slot the old one stands in; the slot decides what that means. */
32
+ export declare function replaceNode(old: ValueNode, node: ValueNode): void;
33
+ /** Takes the node out of its slot with its edge trivia; only a value of an item with a head and the root can be empty. */
34
+ export declare function detachNode(node: ValueNode): void;
35
+ /** Puts the node into the place of the root: a block gets the indentation of the old root block, or none. */
36
+ export declare function setDocumentNode(document: DocumentNode, node: ValueNode | null): void;
37
+ /** The item at the path, or an error naming the first key that is not there. */
38
+ export declare function resolvePath(document: DocumentNode, path: readonly PathSegment[], fullPath?: readonly PathSegment[]): ArrayItemNode;
39
+ /** The array of a container: the array itself, or the attributes of an entity. */
40
+ export declare function arrayOf(node: Node | null): ArrayNode | null;
41
+ export declare function setByPath(document: DocumentNode, path: readonly PathSegment[], value: unknown, create: boolean): void;