diff --git a/debug.html b/debug.html
index c79ae55..7019993 100644
--- a/debug.html
+++ b/debug.html
@@ -6,6 +6,7 @@
     <script src="js/debug.js"></script>
     <script>
       document.body.append(...TickDebug());
+      document.body.append(...IdvDebug());
     </script>
   </body>
 </html>
diff --git a/debug.ts b/debug.ts
index 19908a0..b181e09 100644
--- a/debug.ts
+++ b/debug.ts
@@ -1,3 +1,4 @@
 import { TickDebug } from "./debug/tick";
+import { IdvDebug } from "./debug/idv";
 
-Object.assign(globalThis, { TickDebug });
+Object.assign(globalThis, { TickDebug, IdvDebug });
diff --git a/debug/idv.ts b/debug/idv.ts
new file mode 100644
index 0000000..43dbd7e
--- /dev/null
+++ b/debug/idv.ts
@@ -0,0 +1,43 @@
+import { h } from "../lib/html";
+import { Idv, StringFromDocument } from "../lib/idv";
+
+export function IdvDebug() {
+  const textarea = h(
+    "textarea",
+    {
+      cols: 80,
+      rows: 40,
+      oninput(ev) {
+        parse();
+      },
+    },
+    `# Idv Testing Ground
+Uid: 0
+Shell: tclsh
+Group: users
+Group: sudo
+    `
+  );
+
+  const pre = h("pre");
+
+  function parse() {
+    try {
+      const idv = Idv.parse(textarea.value);
+
+      pre.textContent = JSON.stringify(
+        {
+          shell: idv.getProperty("Shell", String, StringFromDocument),
+          groups: idv.getList("Group", String, StringFromDocument),
+        },
+        null,
+        2
+      );
+    } catch (e) {
+      pre.textContent = String(e);
+    }
+  }
+  parse();
+
+  return [textarea, pre];
+}
diff --git a/lib/idv.ts b/lib/idv.ts
new file mode 100644
index 0000000..2521e82
--- /dev/null
+++ b/lib/idv.ts
@@ -0,0 +1,72 @@
+export type DistinguisherParser<T> = (distinguisher: string) => T;
+export type DocumentParser<T> = (document: string[]) => T;
+
+const LEADING_WHITESPACE = /^([ \t]+)/;
+const ENTRY = /^(.+?):\s*(.*)/;
+
+export class Idv {
+  collections: Record<string, undefined | [string, string[]][]> = {};
+
+  public static parse(input: string): Idv {
+    const lines = input.split("\n").map((line) => line.trimEnd());
+    return Idv.parseLines(lines);
+  }
+  static parseLines(input: string[]): Idv {
+    const idv = new Idv();
+    let currentDocument: string[] = [];
+
+    input.forEach((line) => {
+      const indent = LEADING_WHITESPACE.exec(line)?.[1];
+      if (indent) {
+        // TODO
+      } else if (line == "") {
+        // TODO
+      } else if (line[0] == "#") {
+        // skip
+      } else {
+        const matches = ENTRY.exec(line);
+        if (matches) {
+          const [, collection, distinguisher] = matches;
+
+          if (idv.collections[collection] == undefined) {
+            idv.collections[collection] = [];
+          }
+
+          currentDocument = [];
+          idv.collections[collection].push([distinguisher, currentDocument]);
+        } else {
+          throw new Error("Failed to parse a property");
+        }
+      }
+    });
+
+    return idv;
+  }
+
+  public getProperty<T>(
+    name: string,
+    parseDistinguisher: DistinguisherParser<T>,
+    parseDocument: DocumentParser<T>
+  ): T | null {
+    const firstEntry = this.collections[name]?.[0];
+    return firstEntry && firstEntry[1].length > 0
+      ? parseDocument(firstEntry[1])
+      : firstEntry?.[0]
+      ? parseDistinguisher(firstEntry[0])
+      : null;
+  }
+
+  public getList<T>(
+    name: string,
+    parseDistinguisher: DistinguisherParser<T>,
+    parseDocument: DocumentParser<T>
+  ): T[] {
+    return (this.collections[name] ?? []).map(([distinguisher, document]) =>
+      document.length > 0
+        ? parseDocument(document)
+        : parseDistinguisher(distinguisher)
+    );
+  }
+}
+
+export const StringFromDocument = (lines: string[]) => lines.join("\n");